initial code commit
This commit is contained in:
parent
f541c9c124
commit
914dbc728c
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
__pycache__
|
||||||
|
world
|
||||||
|
foxworld.log
|
||||||
|
config.yml
|
30
config.py
Normal file
30
config.py
Normal 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
38
log.py
Normal 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
18
main.py
Normal 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
87
network.py
Normal 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
85
packet.py
Normal 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
17
test.py
Normal 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
42
world.py
Normal 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
26
worldrw.py
Normal 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))
|
Loading…
x
Reference in New Issue
Block a user