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))