├── mcserver ├── __init__.py ├── classes │ ├── __init__.py │ ├── player.py │ ├── packet_encoder.py │ ├── packet_decoder.py │ └── client_connection.py ├── events │ ├── __init__.py │ ├── init.py │ ├── play.py │ ├── login.py │ ├── event_base.py │ └── status.py ├── game │ ├── __init__.py │ ├── abc │ │ ├── __init__.py │ │ └── entity_base.py │ └── entities │ │ ├── __init__.py │ │ └── player.py ├── objects │ ├── __init__.py │ ├── player_registry.py │ ├── server_core.py │ ├── event_handler.py │ └── plugin_manager.py ├── utils │ ├── __init__.py │ ├── logger.py │ ├── misc.py │ └── cryptography.py └── __main__.py ├── requirements.txt ├── mods └── example_mod │ ├── settings.cfg │ └── example_mod.py ├── .gitignore ├── requirements-ci.txt ├── todo.sh ├── .travis.yml ├── README.md ├── .snekrc ├── server.properties ├── setup.py ├── TODO ├── CODE_OF_CONDUCT.md └── .pylintrc /mcserver/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mcserver/classes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mcserver/events/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mcserver/game/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mcserver/objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mcserver/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mcserver/game/abc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mcserver/game/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | quarry 2 | anyio 3 | asks 4 | -------------------------------------------------------------------------------- /mods/example_mod/settings.cfg: -------------------------------------------------------------------------------- 1 | do_log_client_info=true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.py[cod] 3 | build/ 4 | dist/ 5 | *.egg-info/ 6 | -------------------------------------------------------------------------------- /requirements-ci.txt: -------------------------------------------------------------------------------- 1 | https://github.com/izunadevs/snekchek/archive/master.zip 2 | isort 3 | flake8 4 | pylint 5 | -------------------------------------------------------------------------------- /mcserver/__main__.py: -------------------------------------------------------------------------------- 1 | # MCServer 2 | from mcserver.objects.server_core import ServerCore 3 | 4 | ServerCore.run() 5 | -------------------------------------------------------------------------------- /todo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | grep -rniC 2 TODO --exclude=.* --exclude-dir=.* --exclude=todo.sh --exclude=TODO > TODO 2> /dev/null 3 | -------------------------------------------------------------------------------- /mcserver/events/init.py: -------------------------------------------------------------------------------- 1 | from mcserver.events.event_base import Event 2 | 3 | 4 | class HandshakeEvent(Event): 5 | def __init__(self, event: str, *args): 6 | super().__init__(event, *args) 7 | self.hostname: str = args[0] 8 | self.port: int = args[1] 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | dist: xenial 4 | 5 | python: 6 | - "3.6" 7 | - "3.7" 8 | 9 | install: 10 | - pip install -U -r requirements-ci.txt -r requirements.txt 11 | 12 | script: 13 | - snekchek 14 | # - python setup.py install 15 | # - pytest -c .snekrc tests/* 16 | 17 | cache: 18 | - pip 19 | 20 | notifications: 21 | email: false 22 | -------------------------------------------------------------------------------- /mcserver/events/play.py: -------------------------------------------------------------------------------- 1 | from mcserver.events.event_base import Event 2 | 3 | 4 | class PlayerJoinEvent(Event): 5 | def __init__(self, event: str, *args): 6 | super().__init__(event, *args) 7 | self.player = args[0] 8 | 9 | 10 | class PlayerLeaveEvent(Event): 11 | def __init__(self, event: str, *args): 12 | super().__init__(event, *args) 13 | self.player = args[0] 14 | -------------------------------------------------------------------------------- /mods/example_mod/example_mod.py: -------------------------------------------------------------------------------- 1 | # TODO: Fix mods/plugins 2 | from mcserver.objects.plugin_manager import mod, Dependency 3 | 4 | 5 | @mod("example.dependent", "1.12.2", "0.0.1", [Dependency("example.mod", "0.0.0")]) 6 | class ExampleDependent: 7 | def __init__(self): 8 | print("This will run second") 9 | 10 | 11 | @mod("example.mod", "1.12.2", "0.0.1") 12 | class ExampleMod: 13 | def __init__(self): 14 | print("This will run first") 15 | -------------------------------------------------------------------------------- /mcserver/events/login.py: -------------------------------------------------------------------------------- 1 | from mcserver.events.event_base import Event 2 | 3 | 4 | class LoginStartEvent(Event): 5 | def __init__(self, event: str, *args): 6 | super().__init__(event, *args) 7 | self.username: str = args[0] 8 | 9 | 10 | class ConfirmEncryptionEvent(Event): 11 | def __init__(self, event: str, *args): 12 | super().__init__(event, *args) 13 | self.secret: bytes = args[0] 14 | self.verify: bytes = args[1] 15 | -------------------------------------------------------------------------------- /mcserver/events/event_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | # Stdlib 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from mcserver.classes.client_connection import ClientConnection 8 | 9 | 10 | class Event: 11 | def __init__(self, event: str, *args): 12 | self.event = event 13 | self.args = args 14 | self._conn: ClientConnection = None 15 | 16 | def __repr__(self): 17 | return f"{self.__class__.__name__}({', '.join(map(repr, self.args))})" 18 | -------------------------------------------------------------------------------- /mcserver/utils/logger.py: -------------------------------------------------------------------------------- 1 | # Stdlib 2 | from logging import DEBUG, getLogger, basicConfig 3 | from typing import Union 4 | 5 | basicConfig(level=DEBUG) 6 | log = getLogger("MC-Server") 7 | log.setLevel(DEBUG) 8 | log.info("Logger ready") 9 | 10 | 11 | def info(data: Union[str, bytes]): 12 | log.info(data) 13 | 14 | 15 | def debug(data: Union[str, bytes]): 16 | log.error(data) 17 | 18 | 19 | def warn(data: Union[str, bytes]): 20 | log.warning(data) 21 | 22 | 23 | def error(data: Union[str, bytes]): 24 | log.error(data) 25 | -------------------------------------------------------------------------------- /mcserver/events/status.py: -------------------------------------------------------------------------------- 1 | from mcserver.events.event_base import Event 2 | 3 | 4 | class PingEvent(Event): 5 | def __init__(self, event: str, *args): 6 | super().__init__(event, *args) 7 | self.value: int = args[0] 8 | 9 | 10 | class StatusEvent(Event): 11 | pass 12 | 13 | 14 | class Connect16Event(Event): 15 | def __init__(self, event: str, *args): 16 | super().__init__(event, *args) 17 | self.protocol: int = args[0] 18 | self.hostname: str = args[1] 19 | self.port: int = args[2] 20 | -------------------------------------------------------------------------------- /mcserver/game/abc/entity_base.py: -------------------------------------------------------------------------------- 1 | # Stdlib 2 | from abc import ABC 3 | 4 | # External Libraries 5 | import numpy as np 6 | 7 | # MCServer 8 | from mcserver.utils.misc import get_free_id 9 | 10 | 11 | class Spawnable(ABC): 12 | def __init__(self): 13 | self.id = next(get_free_id()) 14 | self.position = np.array([0, 0, 0]) 15 | self.dimension = 0 16 | 17 | 18 | class EntityBase(Spawnable): 19 | def __init__(self): 20 | super().__init__() 21 | self.rotation = np.array([0, 0]) 22 | self.metadata = {} 23 | self.flags = 0 24 | 25 | def __repr__(self): 26 | return f"{self.__class__.__name__}(position={self.position}, rotation={self.rotation})" 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCServer 2 | 3 | A minecraft server written in python 4 | 5 | ### Usage 6 | 7 | Install from pypi: 8 | ```sh 9 | $ pip install mcserver # Note: Not yet on pypi due to conflict 10 | $ mcserver 11 | ``` 12 | 13 | Install from source: 14 | ``` 15 | $ git clone git@github.com:martmists/mcserver 16 | $ python mcserver/mcserver 17 | ``` 18 | 19 | ### Features 20 | 21 | Current features: 22 | - Partial support for the minecraft protocol 23 | - Partial official server file interop (server.properties and server.icon so far) 24 | 25 | Planned features: 26 | - Full support for the minecraft protocol 27 | - Full server-side game implementation 28 | - Full official server file interop 29 | - Plugins/Modding support 30 | -------------------------------------------------------------------------------- /.snekrc: -------------------------------------------------------------------------------- 1 | [all] 2 | linters = flake8, pylint, isort, vulture 3 | 4 | [flake8] 5 | max-line-length = 120 6 | ignore = E731,W504 7 | exclude = build,tests 8 | 9 | [isort] 10 | line_length = 120 11 | indent = ' ' 12 | multi_line_output = 0 13 | length_sort = 1 14 | use_parentheses = true 15 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 16 | import_heading_future = Future patches 17 | import_heading_stdlib = Stdlib 18 | import_heading_thirdparty = External Libraries 19 | import_heading_firstparty = MCServer 20 | force_sort_within_sections = true 21 | 22 | [vulture] 23 | min-confidence = 0 24 | 25 | [pypi] # needed for twine 26 | version=0.0.1 27 | 28 | [style] # needed for yapf, made an issue to their repo to allow [yapf] in non-`setup.cfg` files 29 | 30 | [pytest] 31 | testpaths = tests 32 | -------------------------------------------------------------------------------- /server.properties: -------------------------------------------------------------------------------- 1 | #Minecraft server properties 2 | generator-settings= 3 | force-gamemode=false 4 | allow-nether=true 5 | enforce-whitelist=false 6 | gamemode=0 7 | broadcast-console-to-ops=true 8 | enable-query=false 9 | player-idle-timeout=0 10 | difficulty=1 11 | spawn-monsters=true 12 | op-permission-level=4 13 | pvp=true 14 | snooper-enabled=true 15 | level-type=DEFAULT 16 | hardcore=false 17 | enable-command-block=false 18 | max-players=20 19 | network-compression-threshold=256 20 | resource-pack-sha1= 21 | max-world-size=29999984 22 | server-port=25565 23 | server-ip= 24 | spawn-npcs=true 25 | allow-flight=false 26 | level-name=world 27 | view-distance=10 28 | resource-pack= 29 | spawn-animals=true 30 | white-list=false 31 | generate-structures=true 32 | online-mode=true 33 | max-build-height=256 34 | level-seed= 35 | prevent-proxy-connections=false 36 | use-native-transport=true 37 | enable-rcon=false 38 | motd=A Python Minecraft Server 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # External Libraries 2 | from setuptools import setup, find_packages 3 | 4 | 5 | if __name__ == '__main__': 6 | setup( 7 | name="mcserver", 8 | author="martmists", 9 | author_email="mail@martmists.com", 10 | license="All Rights Reserved", 11 | zip_safe=False, 12 | version="0.0.1", 13 | description="A minecraft server written in python", 14 | long_description="TODO", 15 | url="https://github.com/martmists/MCServer", 16 | packages=find_packages(), 17 | install_requires=["anyio"], 18 | keywords=["Minecraft", "Python", "Server"], 19 | classifiers=[ 20 | "Development Status :: 2 - Pre-Alpha", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python :: 3.7", 23 | "Topic :: Software Development :: Libraries :: Python Modules" 24 | ], 25 | python_requires=">=3.7") 26 | -------------------------------------------------------------------------------- /mcserver/classes/player.py: -------------------------------------------------------------------------------- 1 | # Future patches 2 | from __future__ import annotations 3 | 4 | # Stdlib 5 | from typing import TYPE_CHECKING 6 | 7 | # MCServer 8 | from mcserver.game.entities.player import EntityPlayer 9 | 10 | if TYPE_CHECKING: 11 | from mcserver.classes.client_connection import ClientConnection 12 | 13 | 14 | class Player: 15 | def __init__(self, conn: ClientConnection): 16 | self.conn = conn 17 | self.entity = EntityPlayer() 18 | self.uuid = self.entity.uuid = conn.uuid 19 | self.name = self.entity.name = conn.name 20 | 21 | self.ping = 0 22 | self.properties = {} 23 | 24 | async def load(self): 25 | # TODO: 26 | # Load from file 27 | # Load from API 28 | pass 29 | 30 | @property 31 | def display_name(self): 32 | return self.entity.display_name 33 | 34 | @property 35 | def gamemode(self): 36 | return self.entity.gamemode 37 | -------------------------------------------------------------------------------- /mcserver/game/entities/player.py: -------------------------------------------------------------------------------- 1 | # Future patches 2 | from __future__ import annotations 3 | 4 | # Stdlib 5 | from typing import TYPE_CHECKING 6 | 7 | # MCServer 8 | from mcserver.game.abc.entity_base import EntityBase 9 | 10 | if TYPE_CHECKING: 11 | from uuid import UUID 12 | 13 | 14 | class EntityPlayer(EntityBase): 15 | def __init__(self): 16 | super().__init__() 17 | self.uuid: UUID = None 18 | self.name = "" 19 | self.display_name = "" 20 | self.gamemode = 1 21 | self.flying = False 22 | self.walk_speed = 1.0 23 | self.fly_speed = 1.0 24 | 25 | @property 26 | def abilities(self): 27 | flags = 0 28 | if self.flying: 29 | flags |= 0x02 30 | if self.gamemode in (1, 3): 31 | flags |= 0x04 32 | if self.gamemode in (1, 3): 33 | flags |= 0x08 34 | if self.gamemode == 1: 35 | flags |= 0x01 36 | return flags 37 | -------------------------------------------------------------------------------- /mcserver/objects/player_registry.py: -------------------------------------------------------------------------------- 1 | # Future patches 2 | from __future__ import annotations 3 | 4 | # Stdlib 5 | from typing import TYPE_CHECKING 6 | from uuid import UUID 7 | 8 | # MCServer 9 | from mcserver.classes.player import Player 10 | 11 | if TYPE_CHECKING: 12 | from typing import List, Union 13 | from mcserver.classes.client_connection import ClientConnection 14 | 15 | 16 | class PlayerRegistry: 17 | players: List[Player] = [] 18 | 19 | @classmethod 20 | def player_count(cls) -> int: 21 | return len(cls.players) 22 | 23 | @classmethod 24 | def get_player(cls, uuid: UUID) -> Player: 25 | return [p for p in cls.players if p.uuid == uuid][0] 26 | 27 | @classmethod 28 | def add_player(cls, player: ClientConnection) -> Player: 29 | player_obj = Player(player) 30 | cls.players.append(player_obj) 31 | return player_obj 32 | 33 | @classmethod 34 | def remove_player(cls, player: Union[Player, UUID]): 35 | if isinstance(player, UUID): 36 | player = cls.get_player(player) 37 | 38 | cls.players.remove(player) 39 | -------------------------------------------------------------------------------- /mcserver/objects/server_core.py: -------------------------------------------------------------------------------- 1 | # Stdlib 2 | from typing import List 3 | 4 | # External Libraries 5 | from anyio import run, create_task_group, create_tcp_server 6 | from quarry.data import packets 7 | 8 | # MCServer 9 | from mcserver.utils.cryptography import export_public_key, make_keypair 10 | from mcserver.utils.misc import DEFAULT_SERVER_PROPERTIES, read_config 11 | 12 | 13 | class ServerCore: 14 | # TODO: 15 | # Refactor auth in a different object 16 | auth_timeout = 30 17 | options = DEFAULT_SERVER_PROPERTIES 18 | with open("server.properties") as fp: 19 | override = read_config(fp) 20 | options.update(override) 21 | 22 | keypair = make_keypair() 23 | pubkey = export_public_key(keypair) 24 | minecraft_versions = [ 25 | "1.7", 26 | "1.8", 27 | "1.9", 28 | "1.10", 29 | "1.11", 30 | "1.12", 31 | "1.13" 32 | ] 33 | 34 | @classmethod 35 | def supported_protocols(cls) -> List[int]: 36 | return [k for k, v in packets.minecraft_versions.items() if any(v.startswith(ver) for ver in cls.minecraft_versions)] 37 | 38 | @classmethod 39 | async def start(cls): 40 | from mcserver.classes.client_connection import ClientConnection 41 | async with create_task_group() as tg: 42 | async with await create_tcp_server(cls.options["server-port"], "0.0.0.0") as server: 43 | async for client in server.accept_connections(): 44 | # await client.start_tls() 45 | conn = ClientConnection(client) 46 | await tg.spawn(conn.serve) 47 | 48 | @classmethod 49 | def run(cls): 50 | try: 51 | run(cls.start, backend="curio") 52 | except KeyboardInterrupt: 53 | pass 54 | -------------------------------------------------------------------------------- /mcserver/utils/misc.py: -------------------------------------------------------------------------------- 1 | # Stdlib 2 | from base64 import b64encode 3 | import inspect 4 | from os.path import join, isfile, dirname 5 | from typing import Optional, Tuple 6 | 7 | DEFAULT_SERVER_PROPERTIES = { 8 | 'generator-settings': '', 9 | 'force-gamemode': False, 10 | 'allow-nether': True, 11 | 'enforce-whitelist': False, 12 | 'gamemode': 0, 13 | 'broadcast-console-to-ops': True, 14 | 'enable-query': False, 15 | 'player-idle-timeout': 0, 16 | 'difficulty': 1, 17 | 'spawn-monsters': True, 18 | 'op-permission-level': 4, 19 | 'pvp': True, 20 | 'snooper-enabled': True, 21 | 'level-type': 'DEFAULT', 22 | 'hardcore': False, 23 | 'enable-command-block': False, 24 | 'max-players': 20, 25 | 'network-compression-threshold': 256, 26 | 'resource-pack-sha1': '', 27 | 'max-world-size': 29999984, 28 | 'server-port': 25565, 29 | 'server-ip': '', 30 | 'spawn-npcs': True, 31 | 'allow-flight': False, 32 | 'level-name': 'world', 33 | 'view-distance': 10, 34 | 'resource-pack': '', 35 | 'spawn-animals': True, 36 | 'white-list': False, 37 | 'generate-structures': True, 38 | 'online-mode': True, 39 | 'max-build-height': 256, 40 | 'level-seed': '', 41 | 'prevent-proxy-connections': False, 42 | 'use-native-transport': True, 43 | 'enable-rcon': False, 44 | 'motd': 'A Minecraft Server' 45 | } 46 | 47 | 48 | def get_free_id(): 49 | x = 0 50 | while True: 51 | yield x 52 | x += 1 53 | 54 | 55 | def open_local(filename: str): 56 | dir_name = dirname(inspect.stack()[1].filename) 57 | return open(join(dir_name, filename)) 58 | 59 | 60 | def read_favicon() -> Optional[str]: 61 | if not isfile('server.icon'): 62 | return None 63 | 64 | with open('server.icon', "rb") as f: 65 | content = b64encode(f.read()) 66 | 67 | return content.decode() 68 | 69 | 70 | def read_config(file): 71 | data = {} 72 | for line in file.readlines(): 73 | line = line.strip() 74 | if line and not line.startswith("#"): 75 | k, v = line.split("=", 1) 76 | if v.isdecimal(): 77 | v = int(v) 78 | if v in ("true", "false"): 79 | v = v == "true" 80 | data[k] = v 81 | return data 82 | 83 | 84 | def map_version(version: str) -> Tuple: 85 | return tuple(map(int, version.split("."))) 86 | -------------------------------------------------------------------------------- /mcserver/classes/packet_encoder.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import struct 4 | 5 | from mcserver.objects.server_core import ServerCore 6 | 7 | 8 | class PacketEncoder: 9 | def __init__(self, protocol: int): 10 | self.protocol = protocol 11 | self.buffer: io.BytesIO = None 12 | 13 | def write(self, fmt: str, args): 14 | self.buffer.write(struct.pack(">"+fmt, args)) 15 | 16 | def write_varint(self, number: int): 17 | if number < 0: 18 | number += 1 << 32 19 | 20 | for i in range(10): 21 | b = number & 0x7F 22 | number >>= 7 23 | self.write("B", b | (0x80 if number > 0 else 0)) 24 | if number == 0: 25 | break 26 | 27 | def write_position(self, x, y, z): 28 | def pack_twos_comp(bits, number): 29 | if number < 0: 30 | number = number + (1 << bits) 31 | return number 32 | 33 | self.write('Q', sum(( 34 | pack_twos_comp(26, x) << 38, 35 | pack_twos_comp(12, y) << 26, 36 | pack_twos_comp(26, z)))) 37 | 38 | def write_bytes(self, data: bytes): 39 | self.write_varint(len(data)) 40 | self.buffer.write(data) 41 | 42 | def write_string(self, text: str, encoding="utf-8"): 43 | self.write_bytes(text.encode(encoding)) 44 | 45 | def write_json(self, data: dict): 46 | self.write_string(json.dumps(data)) 47 | 48 | def encode(self, packet_id: str, args) -> bytes: 49 | self.buffer = io.BytesIO() 50 | 51 | if packet_id == "status": 52 | self.encode_status(*args) 53 | elif packet_id == "pong": 54 | self.encode_pong(*args) 55 | elif packet_id == "encryption_start": 56 | self.encode_encryption_start(*args) 57 | else: 58 | raise 59 | 60 | self.buffer.seek(0) 61 | data = self.buffer.read() 62 | 63 | self.buffer = io.BytesIO() 64 | self.write_varint(len(data)) 65 | self.buffer.seek(0) 66 | 67 | return self.buffer.read() + data 68 | 69 | def encode_status(self, data: dict): 70 | self.write_varint(0) # `status` code 71 | self.write_json(data) 72 | 73 | def encode_pong(self, arg: int): 74 | self.write_varint(1) # `pong` code 75 | self.write("q", arg) 76 | 77 | def encode_encryption_start(self, server_id: str, verify_token: bytes): 78 | self.write_varint(1) # `encryption_start` code 79 | self.write_string(server_id) 80 | self.write_bytes(ServerCore.pubkey) 81 | self.write_bytes(verify_token) 82 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | mods/example_mod/example_mod.py:1:# TODO: Fix mods/plugins 2 | mods/example_mod/example_mod.py-2- 3 | mods/example_mod/example_mod.py-3-# External Libraries 4 | -- 5 | mcserver/classes/player.py-23- 6 | mcserver/classes/player.py-24- async def load(self): 7 | mcserver/classes/player.py:25: # TODO: 8 | mcserver/classes/player.py-26- # Load from file 9 | mcserver/classes/player.py-27- # Load from API 10 | -- 11 | mcserver/classes/client_connection.py-29-class ClientConnection: 12 | mcserver/classes/client_connection.py-30- def __init__(self, client: SocketStream): 13 | mcserver/classes/client_connection.py:31: # TODO: 14 | mcserver/classes/client_connection.py-32- # Refactor this class properties to delegate as much as possible to a different class 15 | mcserver/classes/client_connection.py-33- # This class should only handle incoming/outgoing data and triggering events 16 | -- 17 | mcserver/classes/client_connection.py-58- @property 18 | mcserver/classes/client_connection.py-59- def packet_decoder(self): 19 | mcserver/classes/client_connection.py:60: # TODO: Implement class 20 | mcserver/classes/client_connection.py-61- return PacketDecoder(self.protocol_version) 21 | mcserver/classes/client_connection.py-62- 22 | mcserver/classes/client_connection.py-63- @property 23 | mcserver/classes/client_connection.py-64- def packet_encoder(self): 24 | mcserver/classes/client_connection.py:65: # TODO: Implement class 25 | mcserver/classes/client_connection.py-66- return PacketEncoder(self.protocol_version) 26 | mcserver/classes/client_connection.py-67- 27 | -- 28 | mcserver/classes/client_connection.py-125- # User was logged in 29 | mcserver/classes/client_connection.py-126- debug("Player left, removing from game...") 30 | mcserver/classes/client_connection.py:127: await EventHandler.handle_event(MCEvent("player_leave", self.player)) # TODO: Use PlayerLeaveEvent 31 | mcserver/classes/client_connection.py-128- PlayerRegistry.players.remove(self.player) 32 | mcserver/classes/client_connection.py-129- 33 | -- 34 | mcserver/objects/event_handler.py-51- await listener(*evt.args) 35 | mcserver/objects/event_handler.py-52- 36 | mcserver/objects/event_handler.py:53: # TODO: 37 | mcserver/objects/event_handler.py-54- # Implement all events 38 | -- 39 | mcserver/objects/server_core.py-12- 40 | mcserver/objects/server_core.py-13-class ServerCore: 41 | mcserver/objects/server_core.py:14: # TODO: 42 | mcserver/objects/server_core.py-15- # Refactor auth in a different object 43 | mcserver/objects/server_core.py-16- auth_timeout = 30 44 | -------------------------------------------------------------------------------- /mcserver/utils/cryptography.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import hashlib 4 | 5 | from cryptography.hazmat.primitives import ciphers, serialization 6 | from cryptography.hazmat.primitives.ciphers import algorithms, modes 7 | from cryptography.hazmat.primitives.asymmetric import rsa, padding 8 | from cryptography.hazmat.backends import default_backend 9 | 10 | backend = default_backend() 11 | 12 | PY3 = sys.version_info > (3,) 13 | 14 | 15 | class Cipher(object): 16 | def __init__(self): 17 | self.disable() 18 | 19 | def enable(self, key): 20 | cipher = ciphers.Cipher( 21 | algorithms.AES(key), modes.CFB8(key), backend=backend) 22 | self.encryptor = cipher.encryptor() 23 | self.decryptor = cipher.decryptor() 24 | 25 | def disable(self): 26 | self.encryptor = None 27 | self.decryptor = None 28 | 29 | def encrypt(self, data): 30 | if self.encryptor: 31 | return self.encryptor.update(data) 32 | else: 33 | return data 34 | 35 | def decrypt(self, data): 36 | if self.decryptor: 37 | return self.decryptor.update(data) 38 | else: 39 | return data 40 | 41 | 42 | def make_keypair(): 43 | return rsa.generate_private_key( 44 | public_exponent=65537, 45 | key_size=1024, 46 | backend=default_backend()) 47 | 48 | 49 | def make_server_id(): 50 | data = os.urandom(10) 51 | if PY3: 52 | parts = ["%02x" % c for c in data] 53 | else: 54 | parts = ["%02x" % ord(c) for c in data] 55 | 56 | return "".join(parts) 57 | 58 | 59 | def make_verify_token(): 60 | return os.urandom(4) 61 | 62 | 63 | def make_shared_secret(): 64 | return os.urandom(16) 65 | 66 | 67 | def make_digest(*data): 68 | sha1 = hashlib.sha1() 69 | for d in data: 70 | sha1.update(d) 71 | 72 | digest = int(sha1.hexdigest(), 16) 73 | if digest >> 39*4 & 0x8: 74 | return"-%x" % ((-digest) & (2**(40*4)-1)) 75 | else: 76 | return "%x" % digest 77 | 78 | 79 | def export_public_key(keypair): 80 | return keypair.public_key().public_bytes( 81 | encoding=serialization.Encoding.DER, 82 | format=serialization.PublicFormat.SubjectPublicKeyInfo) 83 | 84 | 85 | def import_public_key(data): 86 | return serialization.load_der_public_key( 87 | data=data, 88 | backend=default_backend()) 89 | 90 | 91 | def encrypt_secret(public_key, shared_secret): 92 | return public_key.encrypt( 93 | plaintext=shared_secret, 94 | padding=padding.PKCS1v15()) 95 | 96 | 97 | def decrypt_secret(keypair, data): 98 | return keypair.decrypt( 99 | ciphertext=data, 100 | padding=padding.PKCS1v15()) 101 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at mail@martmists.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /mcserver/objects/event_handler.py: -------------------------------------------------------------------------------- 1 | # Future patches 2 | from __future__ import annotations 3 | 4 | # Stdlib 5 | from functools import wraps 6 | from typing import TYPE_CHECKING 7 | 8 | # MCServer 9 | from uuid import UUID 10 | 11 | import asks 12 | from anyio import fail_after 13 | from asks import Session 14 | from quarry.data import packets 15 | 16 | from mcserver.events.event_base import Event 17 | from mcserver.events.init import HandshakeEvent 18 | from mcserver.events.login import LoginStartEvent, ConfirmEncryptionEvent 19 | from mcserver.events.status import Connect16Event, StatusEvent, PingEvent 20 | from mcserver.objects.player_registry import PlayerRegistry 21 | from mcserver.objects.server_core import ServerCore 22 | from mcserver.utils.cryptography import make_digest 23 | from mcserver.utils.logger import info 24 | from mcserver.utils.misc import read_favicon 25 | 26 | if TYPE_CHECKING: 27 | from typing import List, Callable, Dict, Optional 28 | 29 | 30 | def event(event_name: Optional[str] = None): 31 | def decorator(func: Callable): 32 | 33 | _event = event_name or func.__name__ 34 | 35 | @wraps 36 | def inner(*args, **kwargs): 37 | return func(*args, **kwargs) 38 | 39 | if _event not in EventHandler.listeners: 40 | raise ValueError(f"Invalid event name: {_event}!" 41 | " If this is a non-standard event, make sure your dependencies loaded!") 42 | 43 | EventHandler.listeners[_event].append(_event) 44 | 45 | return inner 46 | return decorator 47 | 48 | 49 | def register_event(event_name: str): 50 | EventHandler.listeners[event_name] = [] 51 | 52 | 53 | class EventHandler: 54 | listeners: Dict[str, List[Callable]] = { 55 | key: [] 56 | for key in ( 57 | "event_handshake", "event_status", "event_connect_16", "event_ping", "event_login_start", 58 | "event_login_encryption" 59 | ) 60 | } 61 | 62 | @classmethod 63 | async def handle_event(cls, evt: Event): 64 | _event = f"event_{evt.event}" 65 | func = getattr(cls, _event) 66 | await func(evt) 67 | for listener in cls.listeners[_event]: 68 | await listener(evt) 69 | 70 | # TODO: 71 | # Implement all events 72 | @classmethod 73 | async def event_handshake(cls, evt: HandshakeEvent): 74 | pass 75 | 76 | @classmethod 77 | async def event_connect_16(cls, evt: Connect16Event): 78 | pass 79 | 80 | @classmethod 81 | async def event_status(cls, evt: StatusEvent): 82 | data = { 83 | "description": { 84 | "text": ServerCore.options["motd"] 85 | }, 86 | "players": { 87 | "online": PlayerRegistry.player_count(), 88 | "max": ServerCore.options["max-players"] 89 | }, 90 | "version": { 91 | "name": packets.minecraft_versions.get( 92 | evt._conn.protocol_version, 93 | "???"), 94 | "protocol": evt._conn.protocol_version, 95 | } 96 | } 97 | favicon = read_favicon() 98 | if favicon: 99 | data["favicon"] = f"data:image/png;base64,{favicon}" 100 | 101 | evt._conn.send_packet("status", data) 102 | 103 | @classmethod 104 | async def event_ping(cls, evt: PingEvent): 105 | evt._conn.send_packet("pong", evt.value) 106 | 107 | @classmethod 108 | async def event_login_start(cls, evt: LoginStartEvent): 109 | evt._conn.name = evt.username 110 | 111 | if ServerCore.options["online-mode"]: 112 | evt._conn.send_packet( 113 | "encryption_start", 114 | evt._conn.server_id, 115 | evt._conn.verify_token 116 | ) 117 | else: 118 | # TODO: Offline mode 119 | pass 120 | 121 | @classmethod 122 | async def event_login_encryption(cls, evt: ConfirmEncryptionEvent): 123 | if evt.verify != evt._conn.verify_token: 124 | raise Exception("Invalid verification token!") 125 | 126 | evt._conn.cipher.enable(evt.secret) 127 | digest = make_digest( 128 | evt._conn.server_id.encode(), 129 | evt.secret, 130 | ServerCore.pubkey 131 | ) 132 | 133 | url = "https://sessionserver.mojang.com/session/minecraft/hasJoined" 134 | params = { 135 | "username": evt._conn.name, 136 | "serverId": digest 137 | } 138 | if ServerCore.options["prevent-proxy-connections"]: 139 | params["ip"] = evt._conn.client.server_hostname 140 | 141 | async with fail_after(ServerCore.auth_timeout): # FIXME: Fails on curio 142 | resp = await asks.get(url, params=params) 143 | data = resp.json() 144 | info(data) 145 | evt._conn.uuid = UUID(data["id"]) 146 | 147 | evt._conn.packet_decoder.status = 3 148 | return PlayerRegistry.add_player(evt._conn) 149 | -------------------------------------------------------------------------------- /mcserver/classes/packet_decoder.py: -------------------------------------------------------------------------------- 1 | import io 2 | import struct 3 | 4 | from mcserver.events.init import HandshakeEvent 5 | from mcserver.events.login import ConfirmEncryptionEvent, LoginStartEvent 6 | from mcserver.events.status import PingEvent, StatusEvent, Connect16Event 7 | from mcserver.objects.server_core import ServerCore 8 | from mcserver.utils.cryptography import decrypt_secret 9 | from mcserver.utils.logger import debug 10 | 11 | 12 | class PacketDecoder: 13 | def __init__(self, protocol: int, status: int): 14 | self.protocol = protocol 15 | self.status = status 16 | self.buffer: io.BytesIO = None 17 | 18 | def read(self, fmt: str): 19 | fmt = ">" + fmt 20 | size = struct.calcsize(fmt) 21 | vals = struct.unpack(fmt, self.buffer.read(size)) 22 | return vals if len(vals) != 1 else vals[0] 23 | 24 | def read_varint(self) -> int: 25 | number = 0 26 | for i in range(10): 27 | b = self.read("B") 28 | number |= (b & 0x7F) << 7*i 29 | if not b & 0x80: 30 | break 31 | 32 | if number & (1 << 31): 33 | number -= 1 << 32 34 | 35 | return number 36 | 37 | def read_string(self) -> str: 38 | size = self.read_varint() 39 | return self.buffer.read(size).decode() 40 | 41 | def read_position(self): 42 | def unpack_twos_comp(bits, number): 43 | if (number & (1 << (bits - 1))) != 0: 44 | number = number - (1 << bits) 45 | return number 46 | 47 | number = self.read('Q') 48 | x = unpack_twos_comp(26, (number >> 38)) 49 | y = unpack_twos_comp(12, (number >> 26 & 0xFFF)) 50 | z = unpack_twos_comp(26, (number & 0x3FFFFFF)) 51 | return x, y, z 52 | 53 | def decode(self, packet: bytes): 54 | assert packet != b"" 55 | 56 | self.buffer = io.BytesIO(packet) 57 | 58 | if packet[0] == 254: 59 | # TODO: handle 1.6 connection attempt 60 | data = self.decode_connection_16() 61 | return self.buffer.read(), data 62 | 63 | packet_length = self.read_varint() 64 | pos = self.buffer.tell() 65 | assert len(self.buffer.read()) >= packet_length 66 | self.buffer.seek(pos) 67 | 68 | packet_id = self.read_varint() 69 | # debug(f"Packet identifier: {packet_id}") 70 | if packet_id == 0: 71 | if self.status == 0: 72 | data = self.decode_handshake() 73 | elif self.status == 1: 74 | data = self.decode_status() 75 | elif self.status == 2: 76 | data = self.decode_start_login() 77 | elif packet_id == 1: 78 | if self.status == 1: 79 | data = self.decode_ping() 80 | elif self.status == 2: 81 | data = self.decode_encryption() 82 | 83 | try: 84 | data 85 | except NameError: 86 | debug(packet) 87 | raise Exception(f"Unhandled packet ID {packet_id} with data {self.buffer.read()} " 88 | f"while in state {self.status}") 89 | 90 | # Seek in case we leave some data by accident 91 | self.buffer.seek(pos+packet_length) 92 | return self.buffer.read(), data # read the buffer to return remaining bytes 93 | 94 | def decode_connection_16(self): 95 | ident = self.buffer.read(1) 96 | payload = self.buffer.read(1) 97 | sub_ident = self.buffer.read(1) 98 | size = self.read("h") 99 | enc = self.buffer.read(size*2) 100 | ping_host = enc.decode("UTF-16BE") 101 | size_remain = self.read("h") 102 | protocol = self.buffer.read(1)[0] 103 | len_host = self.read("h") * 2 104 | hostname = self.buffer.read(len_host).decode("UTF-16BE") 105 | port = self.read("i") 106 | return Connect16Event("connect_16", protocol, hostname, port) 107 | 108 | def decode_handshake(self): 109 | protocol = self.read_varint() 110 | if protocol in ServerCore.supported_protocols(): 111 | self.protocol = protocol 112 | else: 113 | raise Exception("Invalid protocol") 114 | hostname = self.read_string() 115 | port = self.read("H") 116 | self.status = self.read_varint() 117 | return HandshakeEvent("handshake", hostname, port) 118 | 119 | def decode_status(self): 120 | return StatusEvent("status") 121 | 122 | def decode_ping(self): 123 | return PingEvent("ping", self.read("q")) 124 | 125 | def decode_start_login(self): 126 | return LoginStartEvent("login_start", self.read_string()) 127 | 128 | def decode_encryption(self): 129 | secret = decrypt_secret(ServerCore.keypair, 130 | self.buffer.read(self.read_varint())) 131 | verify = decrypt_secret(ServerCore.keypair, 132 | self.buffer.read(self.read_varint())) 133 | return ConfirmEncryptionEvent("login_encryption", secret, verify) 134 | -------------------------------------------------------------------------------- /mcserver/classes/client_connection.py: -------------------------------------------------------------------------------- 1 | # Future patches 2 | from __future__ import annotations 3 | 4 | # Stdlib 5 | import traceback 6 | from traceback import format_exc 7 | from typing import TYPE_CHECKING, Any, Tuple 8 | from uuid import UUID 9 | 10 | # External Libraries 11 | from anyio import sleep, create_event, create_task_group 12 | from anyio.exceptions import TLSRequired 13 | from quarry.data import packets 14 | 15 | # MCServer 16 | from mcserver.classes.packet_decoder import PacketDecoder 17 | from mcserver.classes.packet_encoder import PacketEncoder 18 | from mcserver.events.play import PlayerLeaveEvent 19 | from mcserver.objects.event_handler import EventHandler 20 | from mcserver.objects.player_registry import PlayerRegistry 21 | from mcserver.utils.cryptography import make_server_id, make_verify_token, Cipher 22 | from mcserver.utils.logger import warn, debug, error 23 | 24 | if TYPE_CHECKING: 25 | from typing import List, Dict, Union, Optional 26 | from anyio import SocketStream, Event 27 | from mcserver.events.event_base import Event as MCEvent 28 | from mcserver.classes.player import Player 29 | 30 | 31 | class ClientConnection: 32 | def __init__(self, client: SocketStream): 33 | # TODO: 34 | # Refactor this class properties to delegate as much as possible to a different class 35 | # This class should only handle incoming/outgoing data and triggering events 36 | self.address = client._socket.getsockname()[0] 37 | self.client = client 38 | self.do_loop = True 39 | self.packet_decoder = PacketDecoder(packets.default_protocol_version, 0) 40 | self.messages: List[bytes] = [] 41 | self._locks: List[ 42 | Dict[str, 43 | Union[ 44 | str, 45 | Event, 46 | Optional[MCEvent] 47 | ]] 48 | ] = [] 49 | self.server_id = make_server_id() 50 | self.verify_token = make_verify_token() 51 | self.cipher = Cipher() 52 | 53 | self.name = "" 54 | self.uuid: UUID = None 55 | 56 | @property 57 | def player(self) -> Player: 58 | return PlayerRegistry.get_player(self.uuid) 59 | 60 | @property 61 | def protocol_version(self) -> int: 62 | return self.packet_decoder.protocol 63 | 64 | @property 65 | def protocol_state(self) -> int: 66 | return self.packet_decoder.status 67 | 68 | @property 69 | def packet_encoder(self): 70 | return PacketEncoder(self.packet_decoder.protocol) 71 | 72 | def __repr__(self): 73 | return (f"ClientConnection(loop={self.do_loop}, " 74 | f"message_queue={len(self.messages)}, " 75 | f"lock_queue={len(self._locks)})") 76 | 77 | async def serve(self): 78 | async with create_task_group() as tg: 79 | await tg.spawn(self.serve_loop) 80 | await tg.spawn(self.write_loop) 81 | 82 | async def serve_loop(self): 83 | data = b"" 84 | run_again = False 85 | async with create_task_group() as tg: 86 | while self.do_loop: 87 | await sleep(0.0001) 88 | if not run_again: 89 | try: 90 | line = await self.client.receive_some(1024) 91 | except ConnectionError: 92 | line = b"" 93 | 94 | if line == b"": 95 | try: 96 | warn(f"Closing connection to {self.client.server_hostname}") 97 | except TLSRequired: 98 | pass 99 | 100 | self.do_loop = False 101 | break 102 | 103 | data += self.cipher.decrypt(line) 104 | 105 | try: 106 | rest_bytes, event = self.packet_decoder.decode(data) 107 | event._conn = self 108 | # debug(f"Left after parsing: {rest_bytes}") 109 | except AssertionError: 110 | run_again = False 111 | continue 112 | else: 113 | data = rest_bytes 114 | if data != b"": 115 | run_again = True 116 | 117 | debug(event) 118 | 119 | for lock in self._locks: 120 | if lock["name"] == event.event: 121 | self._locks.remove(lock) 122 | lock["result"] = event 123 | await lock["lock"].set() 124 | break 125 | 126 | if event.event == "handshake": 127 | await self.handle_msg(event) 128 | else: 129 | await tg.spawn(self.handle_msg, event) 130 | 131 | for lock in self._locks: 132 | await lock["lock"].set() 133 | if self.packet_decoder.status == 3: 134 | # User was logged in 135 | debug("Player left, removing from game...") 136 | await EventHandler.handle_event(PlayerLeaveEvent("player_leave", self.player)) # TODO: Use PlayerLeaveEvent 137 | PlayerRegistry.players.remove(self.player) 138 | 139 | async def handle_msg(self, event: MCEvent): 140 | try: 141 | await EventHandler.handle_event(event) 142 | except Exception: # pylint: disable=broad-except 143 | error(f"Exception occurred:\n{format_exc()}") 144 | 145 | async def write_loop(self): 146 | while self.do_loop: 147 | if self.messages: 148 | msg = self.messages.pop(0) 149 | debug(f"Sending to client: {msg}") 150 | await self.client.send_all(msg) 151 | else: 152 | await sleep(0.00001) # Allow other tasks to run 153 | 154 | async def wait_for_packet(self, packet_name: str) -> Event: 155 | lock = { 156 | "name": packet_name, 157 | "lock": create_event(), 158 | "result": None 159 | } 160 | 161 | self._locks.append(lock) 162 | await lock["lock"].wait() 163 | 164 | return lock["result"] 165 | 166 | def send_packet(self, packet_name: str, *args): 167 | self.messages.append( 168 | self.cipher.encrypt( 169 | self.packet_encoder.encode(packet_name, args) 170 | ) 171 | ) 172 | -------------------------------------------------------------------------------- /mcserver/objects/plugin_manager.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from importlib._bootstrap import module_from_spec 3 | from importlib._bootstrap_external import spec_from_file_location 4 | from os import listdir 5 | from os.path import exists 6 | from typing import List, Optional, Dict, Union, Tuple, Type, Any 7 | 8 | from anyio import Event, create_task_group 9 | 10 | from mcserver.objects.server_core import ServerCore 11 | from mcserver.utils.misc import map_version 12 | 13 | 14 | @dataclass 15 | class Dependency: 16 | dependency_id: str 17 | dependency_min_version: str 18 | dependency_max_version: Optional[str] = None 19 | 20 | 21 | @dataclass 22 | class Extension: 23 | id: str 24 | mc_version: tuple 25 | version: tuple 26 | cls: type 27 | dependencies: List[Dependency] = field(default=[]) 28 | _locks: list = field(default=[]) 29 | _instance: object = field(default=None) 30 | 31 | 32 | def plugin(plugin_id: str, 33 | minecraft_version: str, 34 | plugin_version: str, 35 | dependencies: List[Dependency] = None): 36 | dependencies = dependencies or [] 37 | 38 | def decorator(cls: Type): 39 | if plugin_id in [e.id for e in PluginManager.plugins]: 40 | raise ValueError(f"Plugin with ID {plugin_id} has already been registered!") 41 | PluginManager.plugins.append(Extension( 42 | plugin_id, 43 | map_version(minecraft_version), 44 | map_version(plugin_version), 45 | cls, 46 | dependencies, 47 | )) 48 | return cls 49 | return decorator 50 | 51 | 52 | def mod(mod_id: str, 53 | minecraft_version: str, 54 | mod_version: str, 55 | dependencies: List[Dependency] = None): 56 | dependencies = dependencies or [] 57 | 58 | def decorator(cls: type): 59 | if mod_id in [e.id for e in PluginManager.mods]: 60 | raise ValueError(f"Mod with ID {mod_id} has already been registered!") 61 | PluginManager.mods.append(Extension( 62 | mod_id, 63 | map_version(minecraft_version), 64 | map_version(mod_version), 65 | cls, 66 | dependencies, 67 | )) 68 | return cls 69 | return decorator 70 | 71 | 72 | class PluginManager: 73 | plugins: List[Extension] = [] 74 | mods: List[Extension] = [] 75 | 76 | @classmethod 77 | def get_plugin(cls, plugin_id: str) -> Any: 78 | return [pl for pl in cls.plugins if pl.id == plugin_id][0]._instance 79 | 80 | @classmethod 81 | def get_mod(cls, mod_id: str) -> Any: 82 | return [md for md in cls.mods if md.id == mod_id][0]._instance 83 | 84 | @classmethod 85 | async def search_extensions(cls): 86 | if exists("mods"): 87 | for mod in listdir("mods"): 88 | if exists(f"mods/{mod}/{mod}.py"): 89 | spec = spec_from_file_location("extension.module", f"mods/{mod}/{mod}.py") 90 | modu = module_from_spec(spec) 91 | spec.loader.exec_module(modu) 92 | del modu 93 | 94 | @classmethod 95 | async def prepare(cls): 96 | with create_task_group() as tg: 97 | for plugin_id, plugin_obj in cls.plugins: 98 | await tg.spawn(cls.prepare_plugin, plugin_id, plugin_obj) 99 | for mod_id, mod_obj in cls.mods: 100 | await tg.spawn(cls.prepare_mod, mod_id, mod_obj) 101 | 102 | @classmethod 103 | async def prepare_plugin(cls, plugin_obj: Extension): 104 | lowest_mc_version = min(map(map_version, ServerCore.minecraft_versions)) 105 | 106 | if plugin_obj.mc_version > lowest_mc_version: 107 | raise Exception(f"Expected at least minecraft version {plugin_obj.mc_version}, " 108 | f"got {lowest_mc_version}") 109 | 110 | plugin_locks = [] 111 | 112 | for dep in plugin_obj.dependencies: 113 | if dep.dependency_id not in cls.plugins: 114 | raise Exception(f"Missing dependency {dep.dependency_id} " 115 | f"for plugin {plugin_obj.id}") 116 | 117 | lock = Event() 118 | 119 | dep_obj = cls.get_plugin(dep.dependency_id) 120 | 121 | if dep_obj._instance is None: 122 | dep_obj._locks.append(lock) 123 | plugin_locks.append(lock) 124 | 125 | dep_version = dep_obj.version 126 | 127 | if map_version(dep.dependency_min_version) > dep_version: 128 | raise Exception(f"Dependency {dep.dependency_id} out of date! " 129 | f"Expected at least version {dep.dependency_min_version}, got {dep_version}") 130 | 131 | if dep.dependency_max_version is not None and map_version(dep.dependency_max_version) < dep_version: 132 | raise Exception(f"Dependency {dep.dependency_id} too new! " 133 | f"Expected at most version {dep.dependency_min_version}, got {dep_version}") 134 | 135 | for lock in plugin_locks: 136 | await lock.wait() 137 | 138 | plugin_obj._instance = plugin_obj.cls() 139 | 140 | for lock in plugin_obj._locks: 141 | await lock.set() 142 | 143 | @classmethod 144 | async def prepare_mod(cls, mod_obj: Extension): 145 | lowest_mc_version = min(map(map_version, ServerCore.minecraft_versions)) 146 | 147 | if mod_obj.mc_version > lowest_mc_version: 148 | raise Exception(f"Expected at least minecraft version {mod_obj.mc_version}, " 149 | f"got {lowest_mc_version}") 150 | 151 | mod_locks = [] 152 | 153 | for dep in mod_obj.dependencies: 154 | if dep.dependency_id not in cls.mods: 155 | raise Exception(f"Missing dependency {dep.dependency_id} " 156 | f"for mod {mod_obj.id}") 157 | 158 | lock = Event() 159 | 160 | dep_obj = cls.get_mod(dep.dependency_id) 161 | 162 | if dep_obj._instance is None: 163 | dep_obj._locks.append(lock) 164 | mod_locks.append(lock) 165 | 166 | dep_version = dep_obj.version 167 | 168 | if map_version(dep.dependency_min_version) > dep_version: 169 | raise Exception(f"Dependency {dep.dependency_id} out of date! " 170 | f"Expected at least version {dep.dependency_min_version}, got {dep_version}") 171 | 172 | if dep.dependency_max_version is not None and map_version(dep.dependency_max_version) < dep_version: 173 | raise Exception(f"Dependency {dep.dependency_id} too new! " 174 | f"Expected at most version {dep.dependency_min_version}, got {dep_version}") 175 | 176 | for lock in mod_locks: 177 | await lock.wait() 178 | 179 | mod_obj._instance = mod_obj.cls() 180 | 181 | for lock in mod_obj._locks: 182 | await lock.set() 183 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins= 26 | 27 | # Pickle collected data for later comparisons. 28 | persistent=yes 29 | 30 | # Specify a configuration file. 31 | #rcfile= 32 | 33 | # When enabled, pylint would attempt to guess common misconfiguration and emit 34 | # user-friendly hints instead of false-positive error messages 35 | suggestion-mode=yes 36 | 37 | # Allow loading of arbitrary C extensions. Extensions are imported into the 38 | # active Python interpreter and may run arbitrary code. 39 | unsafe-load-any-extension=no 40 | 41 | 42 | [MESSAGES CONTROL] 43 | 44 | # Only show warnings with the listed confidence levels. Leave empty to show 45 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 46 | confidence= 47 | 48 | # Disable the message, report, category or checker with the given id(s). You 49 | # can either give multiple identifiers separated by comma (,) or put this 50 | # option multiple times (only on the command line, not in the configuration 51 | # file where it should appear only once).You can also use "--disable=all" to 52 | # disable everything first and then reenable specific checks. For example, if 53 | # you want to run only the similarities checker, you can use "--disable=all 54 | # --enable=similarities". If you want to run only the classes checker, but have 55 | # no Warning level messages displayed, use"--disable=all --enable=classes 56 | # --disable=W" 57 | disable=missing-docstring, 58 | invalid-name, 59 | too-few-public-methods, 60 | duplicate-code, 61 | wrong-import-order, 62 | too-many-instance-attributes, 63 | ungrouped-imports, 64 | too-many-public-methods 65 | 66 | # Enable the message, report, category or checker with the given id(s). You can 67 | # either give multiple identifier separated by comma (,) or put this option 68 | # multiple time (only on the command line, not in the configuration file where 69 | # it should appear only once). See also the "--disable" option for examples. 70 | enable=c-extension-no-member 71 | 72 | 73 | [REPORTS] 74 | 75 | # Python expression which should return a note less than 10 (10 is the highest 76 | # note). You have access to the variables errors warning, statement which 77 | # respectively contain the number of errors / warnings messages and the total 78 | # number of statements analyzed. This is used by the global evaluation report 79 | # (RP0004). 80 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 81 | 82 | # Template used to display messages. This is a python new-style format string 83 | # used to format the message information. See doc for all details 84 | #msg-template= 85 | 86 | # Set the output format. Available formats are text, parseable, colorized, json 87 | # and msvs (visual studio).You can also give a reporter class, eg 88 | # mypackage.mymodule.MyReporterClass. 89 | output-format=text 90 | 91 | # Tells whether to display a full report or only the messages 92 | reports=no 93 | 94 | # Activate the evaluation score. 95 | score=yes 96 | 97 | 98 | [REFACTORING] 99 | 100 | # Maximum number of nested blocks for function / method body 101 | max-nested-blocks=5 102 | 103 | # Complete name of functions that never returns. When checking for 104 | # inconsistent-return-statements if a never returning function is called then 105 | # it will be considered as an explicit return statement and no message will be 106 | # printed. 107 | never-returning-functions=optparse.Values,sys.exit 108 | 109 | 110 | [TYPECHECK] 111 | 112 | # List of decorators that produce context managers, such as 113 | # contextlib.contextmanager. Add to this list to register other decorators that 114 | # produce valid context managers. 115 | contextmanager-decorators=contextlib.contextmanager 116 | 117 | # List of members which are set dynamically and missed by pylint inference 118 | # system, and so shouldn't trigger E1101 when accessed. Python regular 119 | # expressions are accepted. 120 | generated-members= 121 | 122 | # Tells whether missing members accessed in mixin class should be ignored. A 123 | # mixin class is detected if its name ends with "mixin" (case insensitive). 124 | ignore-mixin-members=yes 125 | 126 | # This flag controls whether pylint should warn about no-member and similar 127 | # checks whenever an opaque object is returned when inferring. The inference 128 | # can return multiple potential results while evaluating a Python object, but 129 | # some branches might not be evaluated, which results in partial inference. In 130 | # that case, it might be useful to still emit no-member and other checks for 131 | # the rest of the inferred objects. 132 | ignore-on-opaque-inference=yes 133 | 134 | # List of class names for which member attributes should not be checked (useful 135 | # for classes with dynamically set attributes). This supports the use of 136 | # qualified names. 137 | ignored-classes=optparse.Values,thread._local,_thread._local 138 | 139 | # List of module names for which member attributes should not be checked 140 | # (useful for modules/projects where namespaces are manipulated during runtime 141 | # and thus existing member attributes cannot be deduced by static analysis. It 142 | # supports qualified module names, as well as Unix pattern matching. 143 | ignored-modules= 144 | 145 | # Show a hint with possible names when a member name was not found. The aspect 146 | # of finding the hint is based on edit distance. 147 | missing-member-hint=yes 148 | 149 | # The minimum edit distance a name should have in order to be considered a 150 | # similar match for a missing member name. 151 | missing-member-hint-distance=1 152 | 153 | # The total number of similar names that should be taken in consideration when 154 | # showing a hint for a missing member. 155 | missing-member-max-choices=1 156 | 157 | 158 | [SIMILARITIES] 159 | 160 | # Ignore comments when computing similarities. 161 | ignore-comments=yes 162 | 163 | # Ignore docstrings when computing similarities. 164 | ignore-docstrings=yes 165 | 166 | # Ignore imports when computing similarities. 167 | ignore-imports=no 168 | 169 | # Minimum lines number of a similarity. 170 | min-similarity-lines=4 171 | 172 | 173 | [MISCELLANEOUS] 174 | 175 | # List of note tags to take in consideration, separated by a comma. 176 | notes=FIXME, 177 | XXX, 178 | TODO 179 | 180 | 181 | [LOGGING] 182 | 183 | # Logging modules to check that the string format arguments are in logging 184 | # function parameter format 185 | logging-modules=logging 186 | 187 | 188 | [VARIABLES] 189 | 190 | # List of additional names supposed to be defined in builtins. Remember that 191 | # you should avoid to define new builtins when possible. 192 | additional-builtins= 193 | 194 | # Tells whether unused global variables should be treated as a violation. 195 | allow-global-unused-variables=yes 196 | 197 | # List of strings which can identify a callback function by name. A callback 198 | # name must start or end with one of those strings. 199 | callbacks=cb_, 200 | _cb 201 | 202 | # A regular expression matching the name of dummy variables (i.e. expectedly 203 | # not used). 204 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 205 | 206 | # Argument names that match this expression will be ignored. Default to name 207 | # with leading underscore 208 | ignored-argument-names=_.*|^ignored_|^unused_ 209 | 210 | # Tells whether we should check for unused import in __init__ files. 211 | init-import=no 212 | 213 | # List of qualified module names which can have objects that can redefine 214 | # builtins. 215 | redefining-builtins-modules=six.moves,past.builtins,future.builtins 216 | 217 | 218 | [SPELLING] 219 | 220 | # Limits count of emitted suggestions for spelling mistakes 221 | max-spelling-suggestions=4 222 | 223 | # Spelling dictionary name. Available dictionaries: none. To make it working 224 | # install python-enchant package. 225 | spelling-dict= 226 | 227 | # List of comma separated words that should not be checked. 228 | spelling-ignore-words= 229 | 230 | # A path to a file that contains private dictionary; one word per line. 231 | spelling-private-dict-file= 232 | 233 | # Tells whether to store unknown words to indicated private dictionary in 234 | # --spelling-private-dict-file option instead of raising a message. 235 | spelling-store-unknown-words=no 236 | 237 | 238 | [FORMAT] 239 | 240 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 241 | expected-line-ending-format= 242 | 243 | # Regexp for a line that is allowed to be longer than the limit. 244 | ignore-long-lines=^\s*(# )??$ 245 | 246 | # Number of spaces of indent required inside a hanging or continued line. 247 | indent-after-paren=4 248 | 249 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 250 | # tab). 251 | indent-string=' ' 252 | 253 | # Maximum number of characters on a single line. 254 | max-line-length=120 255 | 256 | # Maximum number of lines in a module 257 | max-module-lines=1000 258 | 259 | # List of optional constructs for which whitespace checking is disabled. `dict- 260 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 261 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 262 | # `empty-line` allows space-only lines. 263 | no-space-check=trailing-comma, 264 | dict-separator 265 | 266 | # Allow the body of a class to be on the same line as the declaration if body 267 | # contains single statement. 268 | single-line-class-stmt=no 269 | 270 | # Allow the body of an if to be on the same line as the test if there is no 271 | # else. 272 | single-line-if-stmt=no 273 | 274 | 275 | [BASIC] 276 | 277 | # Naming style matching correct argument names 278 | argument-naming-style=snake_case 279 | 280 | # Regular expression matching correct argument names. Overrides argument- 281 | # naming-style 282 | #argument-rgx= 283 | 284 | # Naming style matching correct attribute names 285 | attr-naming-style=snake_case 286 | 287 | # Regular expression matching correct attribute names. Overrides attr-naming- 288 | # style 289 | #attr-rgx= 290 | 291 | # Bad variable names which should always be refused, separated by a comma 292 | bad-names=foo, 293 | bar, 294 | baz, 295 | toto, 296 | tutu, 297 | tata 298 | 299 | # Naming style matching correct class attribute names 300 | class-attribute-naming-style=any 301 | 302 | # Regular expression matching correct class attribute names. Overrides class- 303 | # attribute-naming-style 304 | #class-attribute-rgx= 305 | 306 | # Naming style matching correct class names 307 | class-naming-style=PascalCase 308 | 309 | # Regular expression matching correct class names. Overrides class-naming-style 310 | #class-rgx= 311 | 312 | # Naming style matching correct constant names 313 | const-naming-style=UPPER_CASE 314 | 315 | # Regular expression matching correct constant names. Overrides const-naming- 316 | # style 317 | #const-rgx= 318 | 319 | # Minimum line length for functions/classes that require docstrings, shorter 320 | # ones are exempt. 321 | docstring-min-length=-1 322 | 323 | # Naming style matching correct function names 324 | function-naming-style=snake_case 325 | 326 | # Regular expression matching correct function names. Overrides function- 327 | # naming-style 328 | #function-rgx= 329 | 330 | # Good variable names which should always be accepted, separated by a comma 331 | good-names=i, 332 | j, 333 | k, 334 | ex, 335 | Run, 336 | _ 337 | 338 | # Include a hint for the correct naming format with invalid-name 339 | include-naming-hint=no 340 | 341 | # Naming style matching correct inline iteration names 342 | inlinevar-naming-style=any 343 | 344 | # Regular expression matching correct inline iteration names. Overrides 345 | # inlinevar-naming-style 346 | #inlinevar-rgx= 347 | 348 | # Naming style matching correct method names 349 | method-naming-style=snake_case 350 | 351 | # Regular expression matching correct method names. Overrides method-naming- 352 | # style 353 | #method-rgx= 354 | 355 | # Naming style matching correct module names 356 | module-naming-style=snake_case 357 | 358 | # Regular expression matching correct module names. Overrides module-naming- 359 | # style 360 | #module-rgx= 361 | 362 | # Colon-delimited sets of names that determine each other's naming style when 363 | # the name regexes allow several styles. 364 | name-group= 365 | 366 | # Regular expression which should only match function or class names that do 367 | # not require a docstring. 368 | no-docstring-rgx=^_ 369 | 370 | # List of decorators that produce properties, such as abc.abstractproperty. Add 371 | # to this list to register other decorators that produce valid properties. 372 | property-classes=abc.abstractproperty 373 | 374 | # Naming style matching correct variable names 375 | variable-naming-style=snake_case 376 | 377 | # Regular expression matching correct variable names. Overrides variable- 378 | # naming-style 379 | #variable-rgx= 380 | 381 | 382 | [DESIGN] 383 | 384 | # Maximum number of arguments for function / method 385 | max-args=5 386 | 387 | # Maximum number of attributes for a class (see R0902). 388 | max-attributes=7 389 | 390 | # Maximum number of boolean expressions in a if statement 391 | max-bool-expr=5 392 | 393 | # Maximum number of branch for function / method body 394 | max-branches=12 395 | 396 | # Maximum number of locals for function / method body 397 | max-locals=15 398 | 399 | # Maximum number of parents for a class (see R0901). 400 | max-parents=7 401 | 402 | # Maximum number of public methods for a class (see R0904). 403 | max-public-methods=20 404 | 405 | # Maximum number of return / yield for function / method body 406 | max-returns=6 407 | 408 | # Maximum number of statements in function / method body 409 | max-statements=50 410 | 411 | # Minimum number of public methods for a class (see R0903). 412 | min-public-methods=2 413 | 414 | 415 | [IMPORTS] 416 | 417 | # Allow wildcard imports from modules that define __all__. 418 | allow-wildcard-with-all=no 419 | 420 | # Analyse import fallback blocks. This can be used to support both Python 2 and 421 | # 3 compatible code, which means that the block might have code that exists 422 | # only in one or another interpreter, leading to false positives when analysed. 423 | analyse-fallback-blocks=no 424 | 425 | # Deprecated modules which should not be used, separated by a comma 426 | deprecated-modules=optparse,tkinter.tix 427 | 428 | # Create a graph of external dependencies in the given file (report RP0402 must 429 | # not be disabled) 430 | ext-import-graph= 431 | 432 | # Create a graph of every (i.e. internal and external) dependencies in the 433 | # given file (report RP0402 must not be disabled) 434 | import-graph= 435 | 436 | # Create a graph of internal dependencies in the given file (report RP0402 must 437 | # not be disabled) 438 | int-import-graph= 439 | 440 | # Force import order to recognize a module as part of the standard 441 | # compatibility libraries. 442 | known-standard-library= 443 | 444 | # Force import order to recognize a module as part of a third party library. 445 | known-third-party=enchant 446 | 447 | 448 | [CLASSES] 449 | 450 | # List of method names used to declare (i.e. assign) instance attributes. 451 | defining-attr-methods=__init__, 452 | __new__, 453 | setUp 454 | 455 | # List of member names, which should be excluded from the protected access 456 | # warning. 457 | exclude-protected=_asdict, 458 | _fields, 459 | _replace, 460 | _source, 461 | _make 462 | 463 | # List of valid names for the first argument in a class method. 464 | valid-classmethod-first-arg=cls 465 | 466 | # List of valid names for the first argument in a metaclass class method. 467 | valid-metaclass-classmethod-first-arg=mcs 468 | 469 | 470 | [EXCEPTIONS] 471 | 472 | # Exceptions that will emit a warning when being caught. Defaults to 473 | # "Exception" 474 | overgeneral-exceptions=Exception 475 | --------------------------------------------------------------------------------