Server with query protocol
This commit is contained in:
parent
75824c306f
commit
f1152cfb25
|
@ -1,8 +1,16 @@
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
|
|
||||||
def main(args: argparse.Namespace) -> int:
|
def main(args: argparse.Namespace) -> int:
|
||||||
|
from sampy.network.protocol import Protocol
|
||||||
|
from sampy.server import Server
|
||||||
|
|
||||||
|
server = Server(Protocol)
|
||||||
|
server.start()
|
||||||
|
|
||||||
|
asyncio.get_event_loop().run_forever()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
15
sampy/client/player.py
Normal file
15
sampy/client/player.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
from ctypes import c_ubyte
|
||||||
|
|
||||||
|
|
||||||
|
class Player:
|
||||||
|
id: c_ubyte
|
||||||
|
username: str
|
||||||
|
score: int
|
||||||
|
ping: int
|
||||||
|
health: float
|
||||||
|
armor: float
|
||||||
|
# position: Vector3 # TODO
|
||||||
|
rotation: float
|
||||||
|
|
||||||
|
def __init__(self, username: str):
|
||||||
|
self.username = username
|
79
sampy/config.py
Normal file
79
sampy/config.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from configparser import ConfigParser
|
||||||
|
from typing import Dict, Mapping, Union
|
||||||
|
|
||||||
|
|
||||||
|
class Config(ConfigParser):
|
||||||
|
DEFAULTS: Mapping[str, Mapping[str, Union[str, int]]] = {
|
||||||
|
"sampy": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 7777,
|
||||||
|
"hostname": "Python > C",
|
||||||
|
"password": "changeme",
|
||||||
|
"rcon_password": "changeme too",
|
||||||
|
"max_players": 50,
|
||||||
|
"mode": "Unknown",
|
||||||
|
"language": "python",
|
||||||
|
},
|
||||||
|
"sampy.rules": {
|
||||||
|
"weburl": "https://git.osufx.com/Sunpy/sampy",
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"filename": "",
|
||||||
|
"level": "INFO",
|
||||||
|
"datefmt": r"%%d-%%b-%%y %%H:%%M:%%S",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *filenames):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.read_dict(self.DEFAULTS)
|
||||||
|
|
||||||
|
found = self.read(filenames)
|
||||||
|
missing = set(filenames) - set(found)
|
||||||
|
|
||||||
|
if len(missing):
|
||||||
|
logging.warn("Config files not found: %s" % missing)
|
||||||
|
|
||||||
|
def save(self, path: str):
|
||||||
|
with open(path, "w") as f:
|
||||||
|
self.write(f)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host(self) -> str:
|
||||||
|
return self.get("sampy", "host")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port(self) -> int:
|
||||||
|
return self.getint("sampy", "port")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hostname(self) -> str:
|
||||||
|
return self.get("sampy", "hostname")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def password(self) -> str:
|
||||||
|
return self.get("sampy", "password")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rcon_password(self) -> str:
|
||||||
|
return self.get("sampy", "rcon_password")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_players(self) -> int:
|
||||||
|
return self.getint("sampy", "max_players")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str:
|
||||||
|
return self.get("sampy", "mode")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def language(self) -> str:
|
||||||
|
return self.get("sampy", "language")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rules(self) -> Dict[str, str]:
|
||||||
|
return self["sampy.rules"]
|
0
sampy/network/__init__.py
Normal file
0
sampy/network/__init__.py
Normal file
12
sampy/network/game.py
Normal file
12
sampy/network/game.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Tuple
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sampy.server import Server
|
||||||
|
|
||||||
|
|
||||||
|
class Game:
|
||||||
|
@staticmethod
|
||||||
|
async def on_packet(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool:
|
||||||
|
return False
|
26
sampy/network/protocol.py
Normal file
26
sampy/network/protocol.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Tuple
|
||||||
|
|
||||||
|
from sampy.network.game import Game
|
||||||
|
from sampy.network.query import Query
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sampy.server import Server
|
||||||
|
|
||||||
|
|
||||||
|
class Protocol:
|
||||||
|
VERSION = "0.3.7"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def on_packet(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool:
|
||||||
|
logging.debug("on_packet")
|
||||||
|
|
||||||
|
if await Query.on_packet(server, packet, addr):
|
||||||
|
return True
|
||||||
|
if await Game.on_packet(server, packet, addr):
|
||||||
|
return True
|
||||||
|
|
||||||
|
logging.debug("Unhandled: %r" % packet)
|
||||||
|
return False
|
145
sampy/network/query.py
Normal file
145
sampy/network/query.py
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from typing import TYPE_CHECKING, Callable, Dict, Tuple
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sampy.server import Server
|
||||||
|
|
||||||
|
|
||||||
|
HANDLERS: Dict[int, Callable[[Server, bytes, Tuple[str, int]], bool]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def handler(opcode: bytes):
|
||||||
|
if len(opcode) > 1:
|
||||||
|
raise Exception("Query opcode length cannot be bigger then 1")
|
||||||
|
|
||||||
|
def outer(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def inner(
|
||||||
|
server: Server, packet: bytes, addr: Tuple[str, int], *args, **kwargs
|
||||||
|
):
|
||||||
|
return func(server, packet, addr, *args, **kwargs)
|
||||||
|
|
||||||
|
HANDLERS[opcode[0]] = func
|
||||||
|
logging.debug("Added query handler:", opcode, func)
|
||||||
|
return inner
|
||||||
|
|
||||||
|
return outer
|
||||||
|
|
||||||
|
|
||||||
|
class Query:
|
||||||
|
HEADER_LENGTH = 11
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def on_packet(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool:
|
||||||
|
if len(packet) < 11: # Packet is too small
|
||||||
|
return False
|
||||||
|
|
||||||
|
magic, _ip, _port, opcode = struct.unpack(
|
||||||
|
b"<4sIHB", packet[: Query.HEADER_LENGTH]
|
||||||
|
) # Unpack packet
|
||||||
|
|
||||||
|
if magic != b"SAMP": # Validate magic
|
||||||
|
return False
|
||||||
|
|
||||||
|
return HANDLERS.get(opcode, lambda *_: False)(server, packet, addr)
|
||||||
|
|
||||||
|
@handler(b"i")
|
||||||
|
@staticmethod
|
||||||
|
def info(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool:
|
||||||
|
packet = packet[: Query.HEADER_LENGTH] # Discard additional data if passed
|
||||||
|
|
||||||
|
len_hostname = len(server.config.hostname)
|
||||||
|
len_mode = len(server.config.mode)
|
||||||
|
len_language = len(server.config.language)
|
||||||
|
|
||||||
|
packet += struct.pack(
|
||||||
|
b"<?HHI%dsI%dsI%ds" % (len_hostname, len_mode, len_language),
|
||||||
|
len(server.config.password) != 0,
|
||||||
|
len(server.players),
|
||||||
|
server.config.max_players,
|
||||||
|
len_hostname,
|
||||||
|
server.config.hostname.encode(),
|
||||||
|
len_mode,
|
||||||
|
server.config.mode.encode(),
|
||||||
|
len_language,
|
||||||
|
server.config.language.encode(),
|
||||||
|
)
|
||||||
|
server.sendto(packet, addr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@handler(b"r")
|
||||||
|
@staticmethod
|
||||||
|
def rules(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool:
|
||||||
|
packet = packet[: Query.HEADER_LENGTH] # Discard additional data if passed
|
||||||
|
|
||||||
|
rules = server.config.rules
|
||||||
|
rules["version"] = server.protocol.VERSION # Add game version (read-only)
|
||||||
|
rules["worldtime"] = ""
|
||||||
|
rules["weather"] = "10"
|
||||||
|
|
||||||
|
packet += struct.pack(b"<H", len(rules))
|
||||||
|
for item in (item.encode() for pair in rules.items() for item in pair):
|
||||||
|
packet += struct.pack(b"<B%ds" % len(item), len(item), item)
|
||||||
|
|
||||||
|
server.sendto(packet, addr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@handler(b"c")
|
||||||
|
@staticmethod
|
||||||
|
def client_list(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool:
|
||||||
|
packet = packet[: Query.HEADER_LENGTH] # Discard additional data if passed
|
||||||
|
|
||||||
|
players = server.players
|
||||||
|
packet += struct.pack(b"<H", len(players))
|
||||||
|
|
||||||
|
for player in players:
|
||||||
|
username = player.username.encode()
|
||||||
|
packet += struct.pack(b"<B%dsI", len(username), username, player.score)
|
||||||
|
|
||||||
|
server.sendto(packet, addr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@handler(b"d")
|
||||||
|
@staticmethod
|
||||||
|
def player_details(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool:
|
||||||
|
packet = packet[: Query.HEADER_LENGTH] # Discard additional data if passed
|
||||||
|
|
||||||
|
players = server.players
|
||||||
|
packet += struct.pack(b"<H", len(players))
|
||||||
|
|
||||||
|
for player in players:
|
||||||
|
username = player.username.encode()
|
||||||
|
packet += struct.pack(
|
||||||
|
b"<BB%dsII",
|
||||||
|
player.id,
|
||||||
|
len(username),
|
||||||
|
username,
|
||||||
|
player.score,
|
||||||
|
player.ping,
|
||||||
|
)
|
||||||
|
|
||||||
|
server.sendto(packet, addr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@handler(b"p")
|
||||||
|
@staticmethod
|
||||||
|
def ping(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool:
|
||||||
|
if (
|
||||||
|
len(packet) < Query.HEADER_LENGTH + 4
|
||||||
|
): # Packet is too small (Missing random)
|
||||||
|
return False
|
||||||
|
|
||||||
|
packet = packet[
|
||||||
|
: Query.HEADER_LENGTH + 4
|
||||||
|
] # Discard additional data if passed (+4 to include random)
|
||||||
|
server.sendto(packet, addr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@handler(b"x")
|
||||||
|
@staticmethod
|
||||||
|
def rcon(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool:
|
||||||
|
return False # TODO
|
63
sampy/server.py
Normal file
63
sampy/server.py
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING, Tuple, Type
|
||||||
|
|
||||||
|
from sampy.client.player import Player
|
||||||
|
from sampy.config import Config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sampy.network.protocol import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class UDPProtocol:
|
||||||
|
transport: asyncio.transports.DatagramTransport
|
||||||
|
|
||||||
|
def __init__(self, protocol: Protocol, local_addr: Tuple[str, int]):
|
||||||
|
self.protocol = protocol
|
||||||
|
self.local_addr = local_addr
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
connect = loop.create_datagram_endpoint(
|
||||||
|
lambda: self,
|
||||||
|
local_addr=self.local_addr,
|
||||||
|
)
|
||||||
|
loop.run_until_complete(connect)
|
||||||
|
|
||||||
|
def stop(self): # TODO: Shutdown code
|
||||||
|
if self.transport is None:
|
||||||
|
raise Exception("Cannot stop a server that hasn't been started")
|
||||||
|
|
||||||
|
self.transport.close()
|
||||||
|
|
||||||
|
def connection_made(self, transport: asyncio.transports.DatagramTransport):
|
||||||
|
self.transport = transport
|
||||||
|
|
||||||
|
def datagram_received(self, data: bytes, addr: Tuple[str, int]):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def sendto(self, data: bytes | bytearray | memoryview, addr: Tuple[str, int]):
|
||||||
|
self.transport.sendto(data, addr)
|
||||||
|
|
||||||
|
|
||||||
|
class Server(UDPProtocol):
|
||||||
|
config: Config
|
||||||
|
|
||||||
|
def __init__(self, protocol: Type[Protocol], config: Config = Config()):
|
||||||
|
super().__init__(
|
||||||
|
protocol=protocol(),
|
||||||
|
local_addr=(
|
||||||
|
config.get("sampy", "host"),
|
||||||
|
config.getint("sampy", "port"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def datagram_received(self, data: bytes, addr: Tuple[str, int]):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(self.protocol.on_packet(self, data, addr))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def players(self) -> list[Player]: # TODO
|
||||||
|
return []
|
Loading…
Reference in New Issue
Block a user