Skip to content

Commit 7d91ad2

Browse files
Merge pull request #27 from Hornochs/main
Adding Support for Nadeo Games
2 parents fd1ecc2 + b779966 commit 7d91ad2

File tree

7 files changed

+255
-1
lines changed

7 files changed

+255
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ from opengsq.protocols import (
2727
Kaillera,
2828
KillingFloor,
2929
Minecraft,
30+
Nadeo,
3031
Palworld,
3132
Quake1,
3233
Quake2,

opengsq/protocol_socket.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ def __init__(self, kind: SocketKind):
2121
self.__protocol = None
2222
self.__kind = kind
2323

24+
async def __aenter__(self):
25+
return self
26+
27+
async def __aexit__(self, exc_type, exc_value, traceback):
28+
self.close()
29+
2430
def __enter__(self):
2531
return self
2632

@@ -59,7 +65,14 @@ def send(self, data: bytes):
5965
else:
6066
self.__transport.sendto(data)
6167

62-
async def recv(self) -> bytes:
68+
async def recv(self, size: int = None) -> bytes:
69+
if size:
70+
data = b""
71+
while len(data) < size:
72+
chunk = await self.__protocol.recv()
73+
data += chunk
74+
if len(data) >= size:
75+
return data[:size]
6376
return await self.__protocol.recv()
6477

6578
class __Protocol(asyncio.Protocol):

opengsq/protocols/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from opengsq.protocols.kaillera import Kaillera
1111
from opengsq.protocols.killingfloor import KillingFloor
1212
from opengsq.protocols.minecraft import Minecraft
13+
from opengsq.protocols.nadeo import Nadeo
1314
from opengsq.protocols.palworld import Palworld
1415
from opengsq.protocols.quake1 import Quake1
1516
from opengsq.protocols.quake2 import Quake2

opengsq/protocols/nadeo.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import struct
5+
import xmlrpc.client as xmlrpclib
6+
from typing import Any, Optional
7+
8+
from opengsq.exceptions import InvalidPacketException
9+
from opengsq.protocol_base import ProtocolBase
10+
from opengsq.responses.nadeo import Status
11+
12+
13+
class Nadeo(ProtocolBase):
14+
full_name = "Nadeo GBXRemote Protocol"
15+
INITIAL_HANDLER = 0x80000000
16+
MAXIMUM_HANDLER = 0xFFFFFFFF
17+
18+
def __init__(self, host: str, port: int = 5000, timeout: float = 5.0):
19+
super().__init__(host, port, timeout)
20+
self.handler = self.MAXIMUM_HANDLER
21+
self._reader: Optional[asyncio.StreamReader] = None
22+
self._writer: Optional[asyncio.StreamWriter] = None
23+
24+
async def connect(self) -> None:
25+
self._reader, self._writer = await asyncio.open_connection(self._host, self._port)
26+
27+
# Read and validate header
28+
data = await self._reader.read(4)
29+
header_length = struct.unpack('<I', data)[0]
30+
31+
data = await self._reader.read(header_length)
32+
header = data.decode()
33+
34+
if header != "GBXRemote 2":
35+
raise InvalidPacketException('No "GBXRemote 2" header found!')
36+
37+
async def close(self) -> None:
38+
if self._writer:
39+
self._writer.close()
40+
await self._writer.wait_closed()
41+
42+
async def __aenter__(self):
43+
await self.connect()
44+
return self
45+
46+
async def __aexit__(self, exc_type, exc_value, traceback):
47+
await self.close()
48+
49+
async def _execute(self, method: str, *args) -> Any:
50+
if self.handler == self.MAXIMUM_HANDLER:
51+
self.handler = self.INITIAL_HANDLER
52+
else:
53+
self.handler += 1
54+
55+
handler_bytes = self.handler.to_bytes(4, byteorder='little')
56+
data = xmlrpclib.dumps(args, method).encode()
57+
packet_len = len(data)
58+
59+
packet = packet_len.to_bytes(4, byteorder='little') + handler_bytes + data
60+
61+
self._writer.write(packet)
62+
await self._writer.drain()
63+
64+
# Read response
65+
header = await self._reader.read(8)
66+
size = struct.unpack('<I', header[:4])[0]
67+
handler = struct.unpack('<I', header[4:8])[0]
68+
69+
if handler != self.handler:
70+
raise InvalidPacketException(f'Handler mismatch: {handler} != {self.handler}')
71+
72+
data = await self._reader.readexactly(size)
73+
74+
try:
75+
response = xmlrpclib.loads(data.decode())
76+
return response[0][0] if response else None
77+
except xmlrpclib.Fault as e:
78+
raise InvalidPacketException(f'RPC Fault: {e}')
79+
80+
async def authenticate(self, username: str, password: str) -> bool:
81+
await self.connect()
82+
result = await self._execute('Authenticate', username, password)
83+
return bool(result)
84+
85+
async def get_status(self) -> Status:
86+
version = await self._execute('GetVersion')
87+
server_info = await self._execute('GetServerOptions')
88+
player_list = await self._execute('GetPlayerList', 100, 0)
89+
current_map = await self._execute('GetCurrentChallengeInfo')
90+
91+
return Status.from_raw_data(
92+
version_data=version,
93+
server_data=server_info,
94+
players_data=player_list,
95+
map_data=current_map
96+
)

opengsq/responses/nadeo/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .status import Status

opengsq/responses/nadeo/status.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from __future__ import annotations
2+
from dataclasses import dataclass
3+
from typing import Any, Optional
4+
5+
6+
@dataclass
7+
class Player:
8+
"""Represents a player on the server."""
9+
login: str
10+
nickname: str
11+
player_id: int
12+
team_id: int
13+
is_spectator: bool
14+
ladder_ranking: int
15+
flags: int
16+
17+
18+
@dataclass
19+
class ServerOptions:
20+
"""Represents server configuration options."""
21+
name: str
22+
comment: str
23+
password: bool
24+
max_players: int
25+
max_spectators: int
26+
current_game_mode: int
27+
current_chat_time: int
28+
hide_server: int
29+
ladder_mode: int
30+
vehicle_quality: int
31+
32+
33+
@dataclass
34+
class Version:
35+
"""Represents the server version information."""
36+
name: str
37+
version: str
38+
build: str
39+
40+
41+
@dataclass
42+
class Status:
43+
version: Version
44+
server_options: ServerOptions
45+
players: list[Player]
46+
map_info: MapInfo # Add this
47+
48+
@classmethod
49+
def from_raw_data(cls, version_data: dict[str, str],
50+
server_data: dict[str, Any],
51+
players_data: list[dict[str, Any]],
52+
map_data: dict[str, Any]) -> Status:
53+
version = Version(
54+
name=version_data.get('Name', ''),
55+
version=version_data.get('Version', ''),
56+
build=version_data.get('Build', '')
57+
)
58+
59+
server_options = ServerOptions(
60+
name=server_data.get('Name', ''),
61+
comment=server_data.get('Comment', ''),
62+
password=server_data.get('Password', False),
63+
max_players=server_data.get('CurrentMaxPlayers', 0),
64+
max_spectators=server_data.get('CurrentMaxSpectators', 0),
65+
current_game_mode=server_data.get('CurrentGameMode', 0),
66+
current_chat_time=server_data.get('CurrentChatTime', 0),
67+
hide_server=server_data.get('HideServer', 0),
68+
ladder_mode=server_data.get('CurrentLadderMode', 0),
69+
vehicle_quality=server_data.get('CurrentVehicleNetQuality', 0)
70+
)
71+
72+
players = [
73+
Player(
74+
login=p.get('Login', ''),
75+
nickname=p.get('NickName', ''),
76+
player_id=p.get('PlayerId', -1),
77+
team_id=p.get('TeamId', -1),
78+
is_spectator=p.get('IsSpectator', False),
79+
ladder_ranking=p.get('LadderRanking', 0),
80+
flags=p.get('Flags', 0)
81+
)
82+
for p in players_data
83+
]
84+
85+
map_info = MapInfo.from_dict(map_data)
86+
87+
return cls(version, server_options, players, map_info)
88+
89+
@dataclass
90+
class MapInfo:
91+
"""Represents current map information."""
92+
name: str
93+
author: str
94+
environment: str
95+
96+
@classmethod
97+
def from_dict(cls, data: dict[str, Any]) -> MapInfo:
98+
return cls(
99+
name=data.get('Name', ''),
100+
author=data.get('Author', ''),
101+
environment=data.get('Environment', '')
102+
)

tests/protocols/test_nadeo.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import pytest
2+
from opengsq.protocols.nadeo import Nadeo
3+
4+
from ..result_handler import ResultHandler
5+
6+
handler = ResultHandler(__file__)
7+
# handler.enable_save = True
8+
9+
# Test server configuration
10+
SERVER_IP = "192.168.100.2"
11+
SERVER_PORT = 5000
12+
SERVER_USER = "SuperAdmin"
13+
SERVER_PASSWORD = "SuperAdmin"
14+
15+
nadeo = Nadeo(host=SERVER_IP, port=SERVER_PORT)
16+
17+
18+
@pytest.mark.asyncio
19+
async def test_authenticate():
20+
result = await nadeo.authenticate(SERVER_USER, SERVER_PASSWORD)
21+
assert result is True
22+
23+
24+
@pytest.mark.asyncio
25+
async def test_get_status():
26+
await nadeo.authenticate(SERVER_USER, SERVER_PASSWORD)
27+
result = await nadeo.get_status()
28+
await handler.save_result("test_get_status", result)
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_get_map_info():
33+
await nadeo.authenticate(SERVER_USER, SERVER_PASSWORD)
34+
result = await nadeo.get_status()
35+
print(f"Server: {result.server_options.name}")
36+
print(f"Map: {result.map_info.name}")
37+
print(f"Author: {result.map_info.author}")
38+
print(f"Players: {len(result.players)}/{result.server_options.max_players}")
39+
assert result.map_info.name != ""
40+
assert result.server_options.name != ""

0 commit comments

Comments
 (0)