From 914dbc728ca78abd5da5104a3a6a85fcf7c85ca5 Mon Sep 17 00:00:00 2001 From: 2o Date: Fri, 11 Apr 2025 11:22:16 +0300 Subject: [PATCH] initial code commit --- .gitignore | 4 +++ config.py | 30 +++++++++++++++++++ log.py | 38 ++++++++++++++++++++++++ main.py | 18 +++++++++++ network.py | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ packet.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ test.py | 17 +++++++++++ world.py | 42 ++++++++++++++++++++++++++ worldrw.py | 26 ++++++++++++++++ 9 files changed, 347 insertions(+) create mode 100644 .gitignore create mode 100644 config.py create mode 100644 log.py create mode 100644 main.py create mode 100644 network.py create mode 100644 packet.py create mode 100644 test.py create mode 100644 world.py create mode 100644 worldrw.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d940c3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +world +foxworld.log +config.yml diff --git a/config.py b/config.py new file mode 100644 index 0000000..c211c68 --- /dev/null +++ b/config.py @@ -0,0 +1,30 @@ +import yaml +import os.path + +print("Loading config.") + +_DEFAULT_CONFIGURATION = """ +log_level: "INFO" +max_players: 10 +world_seed: 0 +net: + host: "::" + port: 7512 +"""[1:] # remove leading \n + +def _load_defconf(): + global c + with open("config.yml", "w") as fp: + fp.write(_DEFAULT_CONFIGURATION) + c = yaml.safe_load(_DEFAULT_CONFIGURATION) + +if os.path.isfile("config.yml"): + with open("config.yml") as fp: + try: + c = yaml.safe_load(fp) + except: + print("Error while loading config! Loading defaults.") + _load_defconf() +else: + print("Non existent config file! Loading defaults.") + _load_defconf() diff --git a/log.py b/log.py new file mode 100644 index 0000000..50c9383 --- /dev/null +++ b/log.py @@ -0,0 +1,38 @@ +import logging + +logger = logging.getLogger("fw") + +RESET_SEQ = "\033[0m" +COLOR_SEQ = "\033[1;%dm" +BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) +COLORS = { + "DEBUG": CYAN, + "INFO": WHITE, + "WARNING": YELLOW, + "ERROR": RED, + "CRITICAL": MAGENTA +} + +class Formatter(logging.Formatter): + def format(self, record): + return COLOR_SEQ % (30 + COLORS.get(record.levelname, 0)) + super().format(record) + RESET_SEQ + +def load(log_level: str) -> None: + logger.setLevel(log_level) + + console_handler = logging.StreamHandler() + file_handler = logging.FileHandler("foxworld.log", mode="a", encoding="utf-8") + + term_formatter = Formatter("[%(asctime)s.%(msecs)03d] [%(threadName)s/%(levelname)s]: %(message)s", datefmt="%Y.%m.%d %H:%M:%S") + file_formatter = logging.Formatter("[%(asctime)s.%(msecs)03d] [%(threadName)s/%(levelname)s]: %(message)s", datefmt="%Y.%m.%d %H:%M:%S") + console_handler.setFormatter(term_formatter) + file_handler .setFormatter(file_formatter) + logger.addHandler(console_handler) + logger.addHandler(file_handler) + +def debug(*args, **kwargs): logger.debug(*args, **kwargs) +def info(*args, **kwargs): logger.info(*args, **kwargs) +def warning(*args, **kwargs): logger.warning(*args, **kwargs) +def error(*args, **kwargs): logger.error(*args, **kwargs) +def critical(*args, **kwargs): logger.critical(*args, **kwargs) +def exception(*args, **kwargs): logger.exception(*args, **kwargs) diff --git a/main.py b/main.py new file mode 100644 index 0000000..63a7844 --- /dev/null +++ b/main.py @@ -0,0 +1,18 @@ +# Requires Python 3.10+ + +VERSION = "dev" + +print(f"Loading FoxWorld server, version {VERSION}") + +import sys, platform, asyncio + +import log, config, network, world + +log.load(config.c["log_level"]) + +log.info(f"System information: FoxWorld {VERSION}; Python {sys.version}; OS: {' '.join(platform.uname())}") + +world_inst = world.World(config.c["world_seed"]) +net = network.Network(config.c["net"]["host"], config.c["net"]["port"], config.c["max_players"], world_inst) + +asyncio.run(net.mainloop()) diff --git a/network.py b/network.py new file mode 100644 index 0000000..83070eb --- /dev/null +++ b/network.py @@ -0,0 +1,87 @@ +import socket, asyncio, string + +import log, packet + +_ALLOWED_NICK_CHARACTERS: str = string.ascii_letters + string.digits + "_" + +class Network: + def __init__(self, host: str, port: int, max_players: int, world): + self.host = host + self.port = port + self.max_players = max_players + self.world = world + self.players: dict = {} + self.player_coros = set() + log.info(f"Starting server at [{self.host}]:{self.port}") + self.server = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) # not sure if this will work with link-local adresses + self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server.bind((self.host, self.port)) + self.server.setblocking(False) + + async def mainloop(self): + loop = asyncio.get_running_loop() + await packet.init() + self.server.listen(4) # if your accept buffer is larger than 4 you should consider how big your server is(or ur pc is too old lol) + log.debug("Listening for connections.") + while True: + client_sock, client_addr = await loop.sock_accept(self.server) + log.debug("Got connection from [%s]:%s" % client_addr[:2]) + loop.create_task(self._auth_player(client_sock, client_addr)) + + async def _auth_player(self, client_sock: socket.socket, client_addr: tuple): + await packet.write_Version(client_sock) + nick: str = await packet.read_Login(client_sock) + if not (3 <= len(nick) <= 15): + log.info("Got invalid nick length %s from [%s]:%s" % (len(nick), *client_addr[:2])) + client_sock.close() + return + for char in nick: + if char not in _ALLOWED_NICK_CHARACTERS: + log.info("Got invalid nick character %s from [%s]:%s" % (ord(char), *client_addr[:2])) + client_sock.close() + return + log.info("[%s]:%s authenticated with nick %s" % (*client_addr[:2], nick)) + if nick in self.players: + log.info("Disconnecting player because another player with this nickname is logged in.") + await packet.write_Disconnect(client_sock, "Already logged in with the same username.") + client_sock.close() + return + if len(self.players) > self.max_players: + log.warning("Disconnecting player because the server is full.") + await packet.write_Disconnect(client_sock, "The server is full.") + client_sock.close() + return + self.players[nick] = [client_sock, client_addr, nick] + # launch new coroutine for the just auth'ed player + coro = asyncio.create_task(self._player_coro(nick)) + self.player_coros.add(coro) + coro.add_done_callback(self.player_coros.discard) + + async def _player_coro(self, nick: str): + sock = self.players[nick][0] + while True: + packet_type, packet_data = await packet.read(sock) + if packet_type is None: + log.info(f"{nick} sent an unknown packet, disconnecting.") + await packet.write_Disconnect(sock, "Wrong packet received.") + sock.close() + del self.players[nick] + return + match packet_type: + case packet.p.C_Quit: + log.info(f"Player {nick} quit with reason {packet_data}.") + sock.close() + del self.players[nick] + return + case packet.p.C_SetCoord: + log.error(f"{nick} sent SetCoord, but it isn't implemented, disconnecting.") + await packet.write_Disconnect(sock, "SetCoord not implemented.") + sock.close() + del self.players[nick] + return + case packet.p.C_Chat: + log.info(f"<{nick}> {packet_data}") + for player in self.players: + await packet.write_Chat(self.players[player][0], packet_data) + case packet.p.C_ChunkRQ: + await packet.write_Chunk(sock, *packet_data, self.world.get_chunk_data(*packet_data)) \ No newline at end of file diff --git a/packet.py b/packet.py new file mode 100644 index 0000000..e2013c0 --- /dev/null +++ b/packet.py @@ -0,0 +1,85 @@ +import struct, asyncio + +import log + +########## +PVN = 0x00 +########## + +class p: + # clientbound packets + C_Quit = 0x00 + C_SetCoord = 0x01 + C_Chat = 0x02 + C_ChunkRQ = 0x03 + + # serverbound packets + S_Disconnect = 0x00 + S_Chunk = 0x01 + S_Chat = 0x02 + S_Spawn = 0x03 + +async def init() -> None: + global loop + loop = asyncio.get_running_loop() + +# this section writes various packets to the client. it doesn't check whether the specified fields are in range, +# if something goes wrong the underlying library(struct, etc.) should throw an exception. basically the calling +# code needs to verify correctness of the data, not these helper functions. + +async def write_Version(sock) -> None: + await loop.sock_sendall(sock, b"FWNP" + PVN.to_bytes(4)) + +async def write_Disconnect(sock, message: str) -> None: + enc_msg: bytes = message.encode("utf-8") + await loop.sock_sendall(sock, struct.pack("!BH", p.S_Disconnect, len(enc_msg)) + enc_msg) + +async def write_Chunk(sock, chunk_x: int, chunk_z: int, chunk_data: bytes) -> None: + await loop.sock_sendall(sock, struct.pack("!BhhH", p.S_Chunk, chunk_x, chunk_z, len(chunk_data)) + chunk_data) + +async def write_Chat(sock, message: str) -> None: + enc_msg: bytes = message.encode("utf-8") + await loop.sock_sendall(sock, struct.pack("!BH", p.S_Chat, len(enc_msg)) + enc_msg) + +async def write_Spawn(sock, spawn_x: float, spawn_y: float, spawn_z: float) -> None: + await loop.sock_sendall(sock, struct.pack("!Bfff", spawn_x, spawn_y, spawn_z)) + +# this functions are the only read functions intended to be used + +async def read(sock) -> tuple: + packet_id: int = int.from_bytes(await _read_bytes(sock, 1)) + match packet_id: + case p.C_Quit: return packet_id, await _read_Quit (sock) + case p.C_SetCoord: return packet_id, await _read_SetCoord(sock) + case p.C_Chat: return packet_id, await _read_Chat (sock) + case p.C_ChunkRQ: return packet_id, await _read_ChunkRQ (sock) + case _: return None, None + +async def read_Login(sock) -> str: + nick_len: int = int.from_bytes(await _read_bytes(sock, 1)) + return (await _read_bytes(sock, nick_len)).decode("ascii") + +# all of these are read helper functions + +async def _read_bytes(sock, n: int) -> bytes or None: + buffer: bytes = b"" + while len(buffer) != n: + data = await loop.sock_recv(sock, n - len(buffer)) + if not data: + return + buffer += data + return buffer + +async def _read_Quit(sock) -> int: + return int.from_bytes(await _read_bytes(sock, 1)) + +async def _read_SetCoord(sock) -> tuple[float]: + return struct.unpack("!fff", await _read_bytes(sock, 12)) + +async def _read_Chat(sock) -> str: + # yes, i know i could've done this in 1 line, but it's more readable this way - 2o, 22aug2024 + message_len: int = int.from_bytes(await _read_bytes(sock, 2)) + return (await _read_bytes(sock, message_len)).decode("utf-8") + +async def _read_ChunkRQ(sock) -> tuple[int]: + return struct.unpack("!hh", await _read_bytes(sock, 4)) diff --git a/test.py b/test.py new file mode 100644 index 0000000..0dd2691 --- /dev/null +++ b/test.py @@ -0,0 +1,17 @@ +import socket + +client = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) +client.connect(("::1", 7512)) + +print(client.recv(4).decode("utf-8")) +print(int.from_bytes(client.recv(4))) +client.send(b"\x04test") +for x in range(-32, 32): + for z in range(-32, 32): + b = b"\x03" + x.to_bytes(2, signed=True) + z.to_bytes(2, signed=True) + print(x, z, b) + client.send(b) +client.send(b"\x02\x00\x12should be done now\x00\x00") + +while client.recv(8192): + "" diff --git a/world.py b/world.py new file mode 100644 index 0000000..637516d --- /dev/null +++ b/world.py @@ -0,0 +1,42 @@ +from perlin_noise import PerlinNoise + +import log, worldrw + +CHUNK_SIZE = 8 +SEED = 0 +NOISE_OCTAVES = 10 +NOISE_SCALE = 1 / (NOISE_OCTAVES * 10) + +class World: + def __init__(self, seed: int): + self.seed = seed + self.noise = PerlinNoise(octaves=NOISE_OCTAVES, seed=self.seed) + + def get_chunk_data(self, x: int, z: int) -> bytes: + if worldrw.chunk_exists(x, z): + log.debug(f"Loading existing chunk {x}.{z}") + try: + return worldrw.get_chunk_data(x, z) + except RuntimeError: + log.critical(f"Error while loading chunk {x}.{z}", exc_info=True) + return b"" + else: + log.info(f"Generating new chunk {x}.{z}") + data: bytes = self._generate_chunk(x, z) + worldrw.save_chunk_data(x, z, data) + return data + + def _generate_chunk(self, x: int, z: int) -> bytes: + heights = [[0 for x in range(CHUNK_SIZE)] for z in range(CHUNK_SIZE)] + log.debug("Generating height map...") + for z in range(CHUNK_SIZE): + for x in range(CHUNK_SIZE): + heights[z][x] = int((self.noise([x * NOISE_SCALE, z * NOISE_SCALE]) + 1) * 128) + log.debug("Allocating chunk...") + chunk = bytearray(b"\x00" * CHUNK_SIZE * CHUNK_SIZE * 256) + log.debug("Generating chunk...") + for x, arr_row in enumerate(heights): + for z, height in enumerate(arr_row): + for y in range(0, height): + chunk[y * CHUNK_SIZE * CHUNK_SIZE + z * CHUNK_SIZE + x] = 1 # stone + return bytes(chunk) diff --git a/worldrw.py b/worldrw.py new file mode 100644 index 0000000..5402861 --- /dev/null +++ b/worldrw.py @@ -0,0 +1,26 @@ +import os +import os.path + +CHUNK_SIZE = 16 +FILE_VERSION = 0 + +def _get_chunk_path(x: int, z: int) -> str: + return f"world/{x}.{z}.fwc" + +def get_chunk_data(x: int, z: int) -> bytes or None: + if os.path.exists(loc := _get_chunk_path(x, z)): + with open(loc, "rb") as fp: + if fp.read(3) != b"FWC": + raise RuntimeError(f"{loc} is not a FWC file!") + if (version := int.from_bytes(fp.read(2))) != FILE_VERSION: + raise RuntimeError(f"{loc} is wrong FWC version! Found {version}, expected {FILE_VERSION}.") + return fp.read() + +def save_chunk_data(x: int, z: int, data: bytes) -> None: + if not os.path.isdir("world"): os.mkdir("world") + with open(_get_chunk_path(x, z), "wb") as fp: + fp.write(b"FWC" + FILE_VERSION.to_bytes(2)) + fp.write(data) + +def chunk_exists(x: int, z: int) -> bool: + return os.path.exists(_get_chunk_path(x, z))