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