mirror of
https://github.com/isledecomp/isle-portable.git
synced 2026-05-21 19:06:35 +00:00
Add room management, relay capacity, and rejection handling
- Remove hardcoded multiplayer config from emscripten config.cpp - Add relay HTTP endpoints for room preview (GET) and creation (POST) with capacity check, CORS headers, and configurable max players - Add WebSocket rejection detection (room full/503) via onclose flag - Add CheckRejected extension call in IsleApp::Tick for clean shutdown through SDL_APP_SUCCESS path instead of calling exit() - Set Module._exitCode in JS for sessionStorage-based toast after reload
This commit is contained in:
parent
a7ba34cada
commit
5b56db3c33
@ -17,12 +17,6 @@ void Emscripten_SetupDefaultConfigOverrides(dictionary* p_dictionary)
|
|||||||
iniparser_set(p_dictionary, "isle:Full Screen", "false");
|
iniparser_set(p_dictionary, "isle:Full Screen", "false");
|
||||||
iniparser_set(p_dictionary, "isle:Flip Surfaces", "true");
|
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-only for now
|
||||||
Emscripten_SetScaleAspect(iniparser_getboolean(p_dictionary, "isle:Original Aspect Ratio", true));
|
Emscripten_SetScaleAspect(iniparser_getboolean(p_dictionary, "isle:Original Aspect Ratio", true));
|
||||||
Emscripten_SetOriginalResolution(iniparser_getboolean(p_dictionary, "isle:Original Resolution", true));
|
Emscripten_SetOriginalResolution(iniparser_getboolean(p_dictionary, "isle:Original Resolution", true));
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <extensions/extensions.h>
|
#include <extensions/extensions.h>
|
||||||
|
#include <extensions/multiplayer.h>
|
||||||
#include <miniwin/miniwindevice.h>
|
#include <miniwin/miniwindevice.h>
|
||||||
#include <type_traits>
|
#include <type_traits>
|
||||||
#include <vec.h>
|
#include <vec.h>
|
||||||
@ -91,6 +92,8 @@
|
|||||||
#include <psp2/kernel/clib.h>
|
#include <psp2/kernel/clib.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
using namespace Extensions;
|
||||||
|
|
||||||
DECOMP_SIZE_ASSERT(IsleApp, 0x8c)
|
DECOMP_SIZE_ASSERT(IsleApp, 0x8c)
|
||||||
|
|
||||||
// GLOBAL: ISLE 0x410030
|
// GLOBAL: ISLE 0x410030
|
||||||
@ -1293,6 +1296,12 @@ inline bool IsleApp::Tick()
|
|||||||
if (!Lego()) {
|
if (!Lego()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Extension<MultiplayerExt>::Call(CheckRejected).value_or(FALSE)) {
|
||||||
|
g_closed = TRUE;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TickleManager()) {
|
if (!TickleManager()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,9 @@ class MultiplayerExt {
|
|||||||
static std::string relayUrl;
|
static std::string relayUrl;
|
||||||
static std::string room;
|
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 void SetNetworkManager(Multiplayer::NetworkManager* p_networkManager);
|
||||||
static Multiplayer::NetworkManager* GetNetworkManager();
|
static Multiplayer::NetworkManager* GetNetworkManager();
|
||||||
|
|
||||||
@ -44,9 +47,11 @@ class MultiplayerExt {
|
|||||||
#ifdef EXTENSIONS
|
#ifdef EXTENSIONS
|
||||||
constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable;
|
constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable;
|
||||||
constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify;
|
constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify;
|
||||||
|
constexpr auto CheckRejected = &MultiplayerExt::CheckRejected;
|
||||||
#else
|
#else
|
||||||
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
|
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
|
||||||
constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr;
|
constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr;
|
||||||
|
constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}; // namespace Extensions
|
}; // namespace Extensions
|
||||||
|
|||||||
@ -39,6 +39,7 @@ class NetworkManager : public MxCore {
|
|||||||
void Connect(const char* p_roomId);
|
void Connect(const char* p_roomId);
|
||||||
void Disconnect();
|
void Disconnect();
|
||||||
bool IsConnected() const;
|
bool IsConnected() const;
|
||||||
|
bool WasRejected() const;
|
||||||
|
|
||||||
void OnWorldEnabled(LegoWorld* p_world);
|
void OnWorldEnabled(LegoWorld* p_world);
|
||||||
void OnWorldDisabled(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 BROADCAST_INTERVAL_MS = 66; // ~15Hz
|
||||||
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
|
static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout
|
||||||
|
static const int EXIT_ROOM_FULL = 10;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Multiplayer
|
} // namespace Multiplayer
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class NetworkTransport {
|
|||||||
virtual void Connect(const char* p_roomId) = 0;
|
virtual void Connect(const char* p_roomId) = 0;
|
||||||
virtual void Disconnect() = 0;
|
virtual void Disconnect() = 0;
|
||||||
virtual bool IsConnected() const = 0;
|
virtual bool IsConnected() const = 0;
|
||||||
|
virtual bool WasRejected() const = 0;
|
||||||
|
|
||||||
// Send binary data to all peers via relay
|
// Send binary data to all peers via relay
|
||||||
virtual void Send(const uint8_t* p_data, size_t p_length) = 0;
|
virtual void Send(const uint8_t* p_data, size_t p_length) = 0;
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class WebSocketTransport : public NetworkTransport {
|
|||||||
void Connect(const char* p_roomId) override;
|
void Connect(const char* p_roomId) override;
|
||||||
void Disconnect() override;
|
void Disconnect() override;
|
||||||
bool IsConnected() const override;
|
bool IsConnected() const override;
|
||||||
|
bool WasRejected() const override;
|
||||||
void Send(const uint8_t* p_data, size_t p_length) override;
|
void Send(const uint8_t* p_data, size_t p_length) override;
|
||||||
size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override;
|
size_t Receive(std::function<void(const uint8_t*, size_t)> p_callback) override;
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ class WebSocketTransport : public NetworkTransport {
|
|||||||
std::string m_relayBaseUrl;
|
std::string m_relayBaseUrl;
|
||||||
int m_socketId;
|
int m_socketId;
|
||||||
volatile int32_t m_connectedFlag; // Shared with JS main thread via Atomics
|
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];
|
uint8_t m_recvBuf[8192];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -100,6 +100,15 @@ MxBool MultiplayerExt::HandleEntityNotify(LegoEntity* p_entity)
|
|||||||
return s_networkManager->HandleEntityMutation(p_entity, changeType);
|
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)
|
void MultiplayerExt::SetNetworkManager(Multiplayer::NetworkManager* p_networkManager)
|
||||||
{
|
{
|
||||||
s_networkManager = p_networkManager;
|
s_networkManager = p_networkManager;
|
||||||
|
|||||||
@ -116,6 +116,11 @@ bool NetworkManager::IsConnected() const
|
|||||||
return m_transport && m_transport->IsConnected();
|
return m_transport && m_transport->IsConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool NetworkManager::WasRejected() const
|
||||||
|
{
|
||||||
|
return m_transport && m_transport->WasRejected();
|
||||||
|
}
|
||||||
|
|
||||||
void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
|
void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
|
||||||
{
|
{
|
||||||
if (!p_world) {
|
if (!p_world) {
|
||||||
|
|||||||
@ -12,10 +12,18 @@ import {
|
|||||||
} from "./protocol";
|
} from "./protocol";
|
||||||
import type { Env } from "./relay";
|
import type { Env } from "./relay";
|
||||||
|
|
||||||
|
const CORS_HEADERS: Record<string, string> = {
|
||||||
|
"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 {
|
export class GameRoom implements DurableObject {
|
||||||
private connections = new Map<number, WebSocket>();
|
private connections = new Map<number, WebSocket>();
|
||||||
private nextPeerId = 1;
|
private nextPeerId = 1;
|
||||||
private hostPeerId = 0;
|
private hostPeerId = 0;
|
||||||
|
private maxPlayers = 5;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private state: DurableObjectState,
|
private state: DurableObjectState,
|
||||||
@ -23,8 +31,17 @@ export class GameRoom implements DurableObject {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async fetch(request: Request): Promise<Response> {
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
// Handle non-WebSocket requests (HTTP API)
|
||||||
if (request.headers.get("Upgrade") !== "websocket") {
|
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();
|
const pair = new WebSocketPair();
|
||||||
@ -49,6 +66,64 @@ export class GameRoom implements DurableObject {
|
|||||||
return new Response(null, { status: 101, webSocket: client });
|
return new Response(null, { status: 101, webSocket: client });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- HTTP API ----
|
||||||
|
|
||||||
|
private async handleHttpRequest(request: Request): Promise<Response> {
|
||||||
|
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 ----
|
// ---- Connection lifecycle ----
|
||||||
|
|
||||||
private assignHostIfNeeded(peerId: number, ws: WebSocket): void {
|
private assignHostIfNeeded(peerId: number, ws: WebSocket): void {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export { GameRoom } from "./gameroom";
|
|||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
GAME_ROOM: DurableObjectNamespace;
|
GAME_ROOM: DurableObjectNamespace;
|
||||||
|
MAX_PLAYERS_CEILING?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@ -7,6 +7,9 @@ bindings = [
|
|||||||
{ name = "GAME_ROOM", class_name = "GameRoom" }
|
{ name = "GAME_ROOM", class_name = "GameRoom" }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
MAX_PLAYERS_CEILING = 64
|
||||||
|
|
||||||
[[migrations]]
|
[[migrations]]
|
||||||
tag = "v1"
|
tag = "v1"
|
||||||
new_sqlite_classes = ["GameRoom"]
|
new_sqlite_classes = ["GameRoom"]
|
||||||
|
|||||||
@ -9,7 +9,7 @@ namespace Multiplayer
|
|||||||
{
|
{
|
||||||
|
|
||||||
WebSocketTransport::WebSocketTransport(const std::string& p_relayBaseUrl)
|
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
|
// clang-format off
|
||||||
MAIN_THREAD_EM_ASM({
|
MAIN_THREAD_EM_ASM({
|
||||||
@ -35,10 +35,13 @@ void WebSocketTransport::Connect(const char* p_roomId)
|
|||||||
|
|
||||||
std::string url = m_relayBaseUrl + "/room/" + p_roomId;
|
std::string url = m_relayBaseUrl + "/room/" + p_roomId;
|
||||||
|
|
||||||
|
m_rejectedFlag = 0;
|
||||||
|
|
||||||
// clang-format off
|
// clang-format off
|
||||||
m_socketId = MAIN_THREAD_EM_ASM_INT({
|
m_socketId = MAIN_THREAD_EM_ASM_INT({
|
||||||
var url = UTF8ToString($0);
|
var url = UTF8ToString($0);
|
||||||
var connPtr = $1;
|
var connPtr = $1;
|
||||||
|
var rejPtr = $2;
|
||||||
var socketId = Module._mpNextSocketId++;
|
var socketId = Module._mpNextSocketId++;
|
||||||
Module._mpMessageQueues[socketId] = [];
|
Module._mpMessageQueues[socketId] = [];
|
||||||
|
|
||||||
@ -58,7 +61,13 @@ void WebSocketTransport::Connect(const char* p_roomId)
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = function() {
|
ws.onclose = function() {
|
||||||
|
var wasConnected = Atomics.load(HEAP32, connPtr >> 2);
|
||||||
Atomics.store(HEAP32, connPtr >> 2, 0);
|
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() {
|
ws.onerror = function() {
|
||||||
@ -72,7 +81,7 @@ void WebSocketTransport::Connect(const char* p_roomId)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return socketId;
|
return socketId;
|
||||||
}, url.c_str(), &m_connectedFlag);
|
}, url.c_str(), &m_connectedFlag, &m_rejectedFlag);
|
||||||
// clang-format on
|
// clang-format on
|
||||||
|
|
||||||
if (m_socketId <= 0) {
|
if (m_socketId <= 0) {
|
||||||
@ -104,6 +113,11 @@ bool WebSocketTransport::IsConnected() const
|
|||||||
return m_socketId > 0 && m_connectedFlag != 0;
|
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)
|
void WebSocketTransport::Send(const uint8_t* p_data, size_t p_length)
|
||||||
{
|
{
|
||||||
if (m_socketId <= 0 || !m_connectedFlag) {
|
if (m_socketId <= 0 || !m_connectedFlag) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user