init
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
./scavgame.x86_64 &
|
||||||
|
GODOT_PID=$!
|
||||||
|
|
||||||
|
wmctrl -r "scavgame" -b add,hidden
|
||||||
|
|
||||||
|
wait "$GODOT_PID"
|
||||||
+1243
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
+906
@@ -0,0 +1,906 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from websockets.asyncio.server import serve
|
||||||
|
from websockets.exceptions import ConnectionClosed
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
HTTP_HOST = "0.0.0.0"
|
||||||
|
HTTP_PORT = 8080
|
||||||
|
|
||||||
|
WS_HOST = "0.0.0.0"
|
||||||
|
WS_PORT = 8081
|
||||||
|
|
||||||
|
GAME_EXECUTABLE = Path(
|
||||||
|
"/data/1/Projects/Scavenger-2027/Build/scavgame.x86_64"
|
||||||
|
)
|
||||||
|
|
||||||
|
GAME_DIRECTORY = GAME_EXECUTABLE.parent
|
||||||
|
WEB_DIRECTORY = GAME_DIRECTORY
|
||||||
|
|
||||||
|
MAX_SESSIONS = 4
|
||||||
|
|
||||||
|
DEBUG_LOGGING = True
|
||||||
|
|
||||||
|
sessions: dict[str, "GameSession"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Logging
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def log(*args):
|
||||||
|
print("[Server]", *args, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def debug(*args):
|
||||||
|
if DEBUG_LOGGING:
|
||||||
|
print("[Server][DEBUG]", *args, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def warning(*args):
|
||||||
|
print("[Server][WARNING]", *args, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def error(*args):
|
||||||
|
print("[Server][ERROR]", *args, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Message helpers
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def normalize_sdp_message(message: dict) -> dict | None:
|
||||||
|
"""
|
||||||
|
Normalizes these formats:
|
||||||
|
|
||||||
|
Browser:
|
||||||
|
{
|
||||||
|
"type": "offer",
|
||||||
|
"offer": {
|
||||||
|
"type": "offer",
|
||||||
|
"sdp": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Godot:
|
||||||
|
{
|
||||||
|
"type": "offer",
|
||||||
|
"sdp": "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
The same applies to answers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
message_type = message.get("type")
|
||||||
|
|
||||||
|
if message_type not in {"offer", "answer"}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
nested_key = message_type
|
||||||
|
|
||||||
|
nested = message.get(nested_key)
|
||||||
|
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
sdp = nested.get("sdp", "")
|
||||||
|
|
||||||
|
if not sdp:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": message_type,
|
||||||
|
"sdp": sdp
|
||||||
|
}
|
||||||
|
|
||||||
|
sdp = message.get("sdp", "")
|
||||||
|
|
||||||
|
if not sdp:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": message_type,
|
||||||
|
"sdp": sdp
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ice_message(message: dict) -> dict | None:
|
||||||
|
"""
|
||||||
|
Normalizes both:
|
||||||
|
|
||||||
|
{ "type": "ice", "candidate": {...} }
|
||||||
|
|
||||||
|
and:
|
||||||
|
|
||||||
|
{ "type": "candidate", "candidate": {...} }
|
||||||
|
"""
|
||||||
|
|
||||||
|
candidate = message.get("candidate")
|
||||||
|
|
||||||
|
if not isinstance(candidate, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not candidate.get("candidate"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "ice",
|
||||||
|
"candidate": candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Game session
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class GameSession:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
browser,
|
||||||
|
requested_session_id: str | None = None
|
||||||
|
):
|
||||||
|
self.id = (
|
||||||
|
requested_session_id
|
||||||
|
if requested_session_id
|
||||||
|
else uuid.uuid4().hex
|
||||||
|
)
|
||||||
|
|
||||||
|
self.browser = browser
|
||||||
|
self.godot = None
|
||||||
|
self.process = None
|
||||||
|
|
||||||
|
self.browser_connected = True
|
||||||
|
self.godot_connected = False
|
||||||
|
self.stopping = False
|
||||||
|
|
||||||
|
self.send_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# Game process
|
||||||
|
# --------------------------------------------------------
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
if not GAME_EXECUTABLE.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Game executable not found: {GAME_EXECUTABLE}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.access(GAME_EXECUTABLE, os.X_OK):
|
||||||
|
raise PermissionError(
|
||||||
|
f"Game is not executable: {GAME_EXECUTABLE}"
|
||||||
|
)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
|
||||||
|
env["SCAV_SESSION_ID"] = self.id
|
||||||
|
|
||||||
|
# Keep Forward+ rendering.
|
||||||
|
env["DISPLAY"] = ":1"
|
||||||
|
|
||||||
|
# Start minimized and avoid taking focus.
|
||||||
|
env["SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS"] = "1"
|
||||||
|
|
||||||
|
command = [
|
||||||
|
str(GAME_EXECUTABLE),
|
||||||
|
|
||||||
|
"--session-id",
|
||||||
|
self.id,
|
||||||
|
|
||||||
|
"--debug-logging",
|
||||||
|
|
||||||
|
"--resolution",
|
||||||
|
"1x1",
|
||||||
|
]
|
||||||
|
|
||||||
|
debug(
|
||||||
|
f"[{self.id}] Starting game command:",
|
||||||
|
command
|
||||||
|
)
|
||||||
|
|
||||||
|
self.process = await asyncio.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
|
||||||
|
cwd=str(GAME_DIRECTORY),
|
||||||
|
env=env,
|
||||||
|
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
log(
|
||||||
|
f"[{self.id}] Started game "
|
||||||
|
f"PID={self.process.pid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.create_task(
|
||||||
|
self.read_output()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def read_output(self):
|
||||||
|
process = self.process
|
||||||
|
|
||||||
|
if process is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if process.stdout is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for line in process.stdout:
|
||||||
|
output = line.decode(
|
||||||
|
errors="replace"
|
||||||
|
).rstrip()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[{self.id}] {output}",
|
||||||
|
flush=True
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
error(
|
||||||
|
f"[{self.id}] Output reader error:",
|
||||||
|
exc
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
if self.stopping:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stopping = True
|
||||||
|
|
||||||
|
process = self.process
|
||||||
|
self.process = None
|
||||||
|
|
||||||
|
if process is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if process.returncode is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
log(
|
||||||
|
f"[{self.id}] Stopping game "
|
||||||
|
f"PID={process.pid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
|
||||||
|
await asyncio.wait_for(
|
||||||
|
process.wait(),
|
||||||
|
timeout=3
|
||||||
|
)
|
||||||
|
|
||||||
|
log(
|
||||||
|
f"[{self.id}] Game exited "
|
||||||
|
f"code={process.returncode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
warning(
|
||||||
|
f"[{self.id}] Game did not exit; "
|
||||||
|
f"killing PID={process.pid}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# WebSocket sending
|
||||||
|
# --------------------------------------------------------
|
||||||
|
|
||||||
|
async def send_json(
|
||||||
|
self,
|
||||||
|
websocket,
|
||||||
|
message: dict
|
||||||
|
) -> bool:
|
||||||
|
if websocket is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.dumps(message)
|
||||||
|
|
||||||
|
async with self.send_lock:
|
||||||
|
await websocket.send(payload)
|
||||||
|
|
||||||
|
debug(
|
||||||
|
f"[{self.id}] Sent:",
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ConnectionClosed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
error(
|
||||||
|
f"[{self.id}] Send error:",
|
||||||
|
exc
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_to_godot(
|
||||||
|
self,
|
||||||
|
message: dict
|
||||||
|
) -> bool:
|
||||||
|
if self.godot is None:
|
||||||
|
warning(
|
||||||
|
f"[{self.id}] Cannot send to Godot; "
|
||||||
|
f"Godot is not connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
success = await self.send_json(
|
||||||
|
self.godot,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
self.godot = None
|
||||||
|
self.godot_connected = False
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
async def send_to_browser(
|
||||||
|
self,
|
||||||
|
message: dict
|
||||||
|
) -> bool:
|
||||||
|
if self.browser is None:
|
||||||
|
warning(
|
||||||
|
f"[{self.id}] Cannot send to browser; "
|
||||||
|
f"browser is not connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
success = await self.send_json(
|
||||||
|
self.browser,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
self.browser = None
|
||||||
|
self.browser_connected = False
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HTTP server
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class HTTPHandler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(
|
||||||
|
*args,
|
||||||
|
directory=str(WEB_DIRECTORY),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_message(self, format_string, *args):
|
||||||
|
print(
|
||||||
|
"[HTTP] " + format_string % args,
|
||||||
|
flush=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_http_server():
|
||||||
|
server = ThreadingHTTPServer(
|
||||||
|
(HTTP_HOST, HTTP_PORT),
|
||||||
|
HTTPHandler
|
||||||
|
)
|
||||||
|
|
||||||
|
log(
|
||||||
|
f"HTTP server listening on "
|
||||||
|
f"http://{HTTP_HOST}:{HTTP_PORT}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Browser connection
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def handle_browser(
|
||||||
|
websocket,
|
||||||
|
requested_session_id: str | None = None
|
||||||
|
):
|
||||||
|
if len(sessions) >= MAX_SESSIONS:
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Server is full"
|
||||||
|
}))
|
||||||
|
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
session = GameSession(
|
||||||
|
websocket,
|
||||||
|
requested_session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.id in sessions:
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Session ID is already in use"
|
||||||
|
}))
|
||||||
|
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
sessions[session.id] = session
|
||||||
|
|
||||||
|
log(
|
||||||
|
f"[{session.id}] Browser connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.send_to_browser({
|
||||||
|
"type": "session_created",
|
||||||
|
"session_id": session.id
|
||||||
|
})
|
||||||
|
|
||||||
|
await session.start()
|
||||||
|
|
||||||
|
# Wait for Godot to connect.
|
||||||
|
for _ in range(100):
|
||||||
|
if session.godot is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
if session.godot is None:
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Godot did not connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.send_to_browser({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Godot did not connect"
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
await session.send_to_godot({
|
||||||
|
"type": "browser_connected"
|
||||||
|
})
|
||||||
|
|
||||||
|
await session.send_to_browser({
|
||||||
|
"type": "godot_connected"
|
||||||
|
})
|
||||||
|
|
||||||
|
log(
|
||||||
|
f"[{session.id}] Browser and Godot paired"
|
||||||
|
)
|
||||||
|
|
||||||
|
async for raw in websocket:
|
||||||
|
try:
|
||||||
|
message = json.loads(raw)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Invalid browser JSON:",
|
||||||
|
raw
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Browser message "
|
||||||
|
f"is not an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
debug(
|
||||||
|
f"[{session.id}] Browser message:",
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
message_type = message.get("type")
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# WebRTC offer
|
||||||
|
# ------------------------------------------------
|
||||||
|
|
||||||
|
if message_type == "offer":
|
||||||
|
normalized = normalize_sdp_message(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized is None:
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Invalid browser offer"
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
await session.send_to_godot(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# WebRTC ICE
|
||||||
|
# ------------------------------------------------
|
||||||
|
|
||||||
|
elif message_type in {
|
||||||
|
"ice",
|
||||||
|
"candidate"
|
||||||
|
}:
|
||||||
|
normalized = normalize_ice_message(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized is None:
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Invalid browser ICE"
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
await session.send_to_godot(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
debug(
|
||||||
|
f"[{session.id}] Ignoring browser message:",
|
||||||
|
message_type
|
||||||
|
)
|
||||||
|
|
||||||
|
except ConnectionClosed:
|
||||||
|
log(
|
||||||
|
f"[{session.id}] Browser WebSocket closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
error(
|
||||||
|
f"[{session.id}] Browser error:",
|
||||||
|
exc
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
log(
|
||||||
|
f"[{session.id}] Browser disconnected"
|
||||||
|
)
|
||||||
|
|
||||||
|
session.browser_connected = False
|
||||||
|
|
||||||
|
if session.godot is not None:
|
||||||
|
await session.send_to_godot({
|
||||||
|
"type": "browser_disconnected"
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.godot.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
session.godot = None
|
||||||
|
session.godot_connected = False
|
||||||
|
|
||||||
|
await session.stop()
|
||||||
|
|
||||||
|
sessions.pop(
|
||||||
|
session.id,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
log(
|
||||||
|
f"[{session.id}] Session removed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Godot connection
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def handle_godot(
|
||||||
|
websocket,
|
||||||
|
session_id: str
|
||||||
|
):
|
||||||
|
session = sessions.get(session_id)
|
||||||
|
|
||||||
|
if session is None:
|
||||||
|
warning(
|
||||||
|
f"[{session_id}] Godot attempted to connect "
|
||||||
|
f"to unknown session"
|
||||||
|
)
|
||||||
|
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Unknown session"
|
||||||
|
}))
|
||||||
|
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
if session.godot is not None:
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Replacing existing Godot connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.godot.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
session.godot = websocket
|
||||||
|
session.godot_connected = True
|
||||||
|
|
||||||
|
log(
|
||||||
|
f"[{session.id}] Godot connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.send_to_godot({
|
||||||
|
"type": "registered",
|
||||||
|
"session_id": session.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if session.browser_connected:
|
||||||
|
await session.send_to_godot({
|
||||||
|
"type": "browser_connected"
|
||||||
|
})
|
||||||
|
|
||||||
|
await session.send_to_browser({
|
||||||
|
"type": "godot_connected"
|
||||||
|
})
|
||||||
|
|
||||||
|
async for raw in websocket:
|
||||||
|
try:
|
||||||
|
message = json.loads(raw)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Invalid Godot JSON:",
|
||||||
|
raw
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Godot message "
|
||||||
|
f"is not an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
debug(
|
||||||
|
f"[{session.id}] Godot message:",
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
message_type = message.get("type")
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# WebRTC answer
|
||||||
|
# ------------------------------------------------
|
||||||
|
|
||||||
|
if message_type == "answer":
|
||||||
|
normalized = normalize_sdp_message(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized is None:
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Invalid Godot answer"
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
await session.send_to_browser({
|
||||||
|
"type": "answer",
|
||||||
|
"answer": {
|
||||||
|
"type": "answer",
|
||||||
|
"sdp": normalized["sdp"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# ------------------------------------------------
|
||||||
|
# WebRTC ICE
|
||||||
|
# ------------------------------------------------
|
||||||
|
|
||||||
|
elif message_type in {
|
||||||
|
"ice",
|
||||||
|
"candidate"
|
||||||
|
}:
|
||||||
|
normalized = normalize_ice_message(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized is None:
|
||||||
|
warning(
|
||||||
|
f"[{session.id}] Invalid Godot ICE"
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
await session.send_to_browser(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
debug(
|
||||||
|
f"[{session.id}] Ignoring Godot message:",
|
||||||
|
message_type
|
||||||
|
)
|
||||||
|
|
||||||
|
except ConnectionClosed:
|
||||||
|
log(
|
||||||
|
f"[{session.id}] Godot WebSocket closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
error(
|
||||||
|
f"[{session.id}] Godot error:",
|
||||||
|
exc
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
log(
|
||||||
|
f"[{session.id}] Godot disconnected"
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.godot is websocket:
|
||||||
|
session.godot = None
|
||||||
|
session.godot_connected = False
|
||||||
|
|
||||||
|
if session.browser is not None:
|
||||||
|
await session.send_to_browser({
|
||||||
|
"type": "godot_disconnected"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# WebSocket routing
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def websocket_handler(websocket):
|
||||||
|
try:
|
||||||
|
raw = await websocket.recv()
|
||||||
|
|
||||||
|
message = json.loads(raw)
|
||||||
|
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
role = message.get("role")
|
||||||
|
|
||||||
|
debug(
|
||||||
|
"Initial WebSocket registration:",
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
if role == "browser":
|
||||||
|
requested_session_id = message.get(
|
||||||
|
"session_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
await handle_browser(
|
||||||
|
websocket,
|
||||||
|
requested_session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
elif role == "godot":
|
||||||
|
session_id = message.get(
|
||||||
|
"session_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Missing session_id"
|
||||||
|
}))
|
||||||
|
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
await handle_godot(
|
||||||
|
websocket,
|
||||||
|
session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Invalid role"
|
||||||
|
}))
|
||||||
|
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
except ConnectionClosed:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
warning(
|
||||||
|
"Received invalid initial WebSocket JSON"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
error(
|
||||||
|
"WebSocket handler error:",
|
||||||
|
exc
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# WebSocket server
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def websocket_server():
|
||||||
|
log(
|
||||||
|
f"WebSocket server listening on "
|
||||||
|
f"ws://{WS_HOST}:{WS_PORT}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with serve(
|
||||||
|
websocket_handler,
|
||||||
|
WS_HOST,
|
||||||
|
WS_PORT
|
||||||
|
):
|
||||||
|
await asyncio.Future()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Main
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
http_task = asyncio.to_thread(
|
||||||
|
start_http_server
|
||||||
|
)
|
||||||
|
|
||||||
|
websocket_task = asyncio.create_task(
|
||||||
|
websocket_server()
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
http_task,
|
||||||
|
websocket_task
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(
|
||||||
|
"\nServer stopped",
|
||||||
|
flush=True
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user