initial code commit

This commit is contained in:
2o 2025-04-11 11:22:16 +03:00
parent f541c9c124
commit 914dbc728c
9 changed files with 347 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__
world
foxworld.log
config.yml

30
config.py Normal file
View File

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

38
log.py Normal file
View File

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

18
main.py Normal file
View File

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

87
network.py Normal file
View File

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

85
packet.py Normal file
View File

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

17
test.py Normal file
View File

@ -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):
""

42
world.py Normal file
View File

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

26
worldrw.py Normal file
View File

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