diff --git a/requirements.txt b/requirements.txt index f7ffe7b..bdd9ba4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ +setproctitle +termcolor apscheduler aiocraft +aioconsole diff --git a/treepuncher/__main__.py b/treepuncher/__main__.py index e69de29..9f82f23 100644 --- a/treepuncher/__main__.py +++ b/treepuncher/__main__.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python +import os +import re +import asyncio +import logging +import argparse + +from pathlib import Path +from importlib import import_module +from typing import get_type_hints +from dataclasses import dataclass + +from setproctitle import setproctitle + +from .treepuncher import Treepuncher, Addon, ConfigObject +from .helpers import configure_logging + +def main(): + root = Path(os.getcwd()) + # TODO would be cool if it was possible to configure addons path, but we need to load addons before doing argparse so we can do helptext + # addon_path = Path(args.addon_path) if args.addon_path else ( root/'addons' ) + addon_path = root/'addons' + addons : List[Type[Addon]] = [] + + for path in sorted(addon_path.rglob('*.py')): + m = import_module(path) + for obj_name in vars(m).keys(): + obj = getattr(m, obj_name) + if issubclass(obj, Addon): + addons.append(obj) + + class ChatLogger(Addon): + """This addon will print (optionally colored) game chat to terminal""" + REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]") + @dataclass + class Options(ConfigObject): + color : bool = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + @self.on_packet(PacketChatMessage) + async def print_chat_colored_to_terminal(packet:PacketChatMessage): + print(self.REMOVE_COLOR_FORMATS.sub("", parse_chat(packet.message, ansi_color=self.config.color))) + + addons.append(ChatLogger) + + class ChatInput(Addon): + task : asyncio.Task + running : bool + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.task = None + self.running = False + + async def initialize(self): + async def aio_input(): + while self.running: + try: + await self.client.chat(await asyncio.wait_for(ainput(""), 1)) + except asyncio.TimeoutError: + pass + except Exception: + client._logger.exception("Exception processing input from keyboard") + self.running = True + self.task = asyncio.get_event_loop().create_task(aio_input()) + + async def cleanup(self, force:bool=False): + self.running = False + if self.task and not force: + await self.task + + addons.append(ChatInput) + + help_text = '\n\naddons:\n' + str.join( # TODO do this iteratively to make it readable! + '\n', ( + f" {addon.__name__}: {addon.__doc__ or '-no description-'}\n " + str.join('\n ', + (f"- {name} ({clazz.__name__}) {'[!]' if not hasattr(addon.Options, name) else ''}" for (name, clazz) in get_type_hints(addon.Options).items()) + ) for addon in addons + ) + ) + + parser = argparse.ArgumentParser( + prog='python -m treepuncher', + description='Treepuncher | Block Game automation framework', + epilog=help_text, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument('name', help='name to use for this client session') + parser.add_argument('server', help='server to connect to') + parser.add_argument('--ms-client-id', dest='client_id', default='c63ef189-23cb-453b-8060-13800b85d2dc', help='Azure application client_id') + parser.add_argument('--ms-client-secret', dest='client_secret', default='N2e7Q~ybYA0IO39KB1mFD4GmoYzISRaRNyi59', help='Azure application client_secret') + parser.add_argument('--ms-redirect-uri', dest='redirect_uri', default='https://fantabos.co/msauth', help='Azure application redirect_uri') + parser.add_argument('--addon-path', dest='addon-path', default='', help='Path for loading addons') + parser.add_argument('--chat-log', dest='chat_log', action='store_const', const=True, default=False, help="print (colored) chat to terminal") + parser.add_argument('--chat-input', dest='chat_input', action='store_const', const=True, default=False, help="read input from stdin and send it to chat") + parser.add_argument('--debug', dest='_debug', action='store_const', const=True, default=False, help="enable debug logs") + parser.add_argument('--no-packet-whitelist', dest='use_packet_whitelist', action='store_const', const=False, default=True, help="disable packet whitelist") + + args = parser.parse_args() + + configure_logging(args.name, level=logging.DEBUG if args._debug else logging.INFO) + setproctitle(f"treepuncher[{args.name}]") + + code = input(f"-> Go to 'https://fantabos.co/msauth?client_id={args.client_id}&state=hardcoded', click 'Auth' and login, then copy here the code you received\n--> ") + + client = Treepuncher( + args.name, + args.server, + use_packet_whitelist=use_packet_whitelist, + notifier=notifier, + client_id=args.client_id, + client_secret=args.client_secret, + redirect_uri="https://fantabos.co/msauth" + ) + + for addon in addons: + client.install(addon) + + if args.chat_log: + client.install(ChatLogger) + + if args.chat_input: + from aioconsole import ainput + client.install(ChatInput) + + client.run() + +if __name__ == "__main__": + main() + + diff --git a/treepuncher/events/__init__.py b/treepuncher/events/__init__.py index 4c27510..f114f17 100644 --- a/treepuncher/events/__init__.py +++ b/treepuncher/events/__init__.py @@ -1 +1,3 @@ from .chat import ChatEvent +from .join_game import JoinGameEvent +from .death import DeathEvent diff --git a/treepuncher/events/base.py b/treepuncher/events/base.py new file mode 100644 index 0000000..3253c86 --- /dev/null +++ b/treepuncher/events/base.py @@ -0,0 +1,2 @@ +class BaseEvent: + pass diff --git a/treepuncher/events/chat.py b/treepuncher/events/chat.py index 3e68fcc..cfa8fb0 100644 --- a/treepuncher/events/chat.py +++ b/treepuncher/events/chat.py @@ -5,6 +5,8 @@ from enum import Enum from aiocraft.util.helpers import parse_chat +from .base import BaseEvent + CHAT_MESSAGE_MATCHER = re.compile(r"<(?P[A-Za-z0-9_]+)> (?P.+)") REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]") WHISPER_MATCHER = re.compile(r"(?:to (?P[A-Za-z0-9_]+)( |):|(?P[A-Za-z0-9_]+) whispers( |):|from (?P[A-Za-z0-9_]+):) (?P.+)", flags=re.IGNORECASE) @@ -19,7 +21,7 @@ class MessageType(Enum): LEAVE = "leave" SYSTEM = "system" -class ChatEvent: +class ChatEvent(BaseEvent): text : str type : MessageType user : str diff --git a/treepuncher/events/death.py b/treepuncher/events/death.py new file mode 100644 index 0000000..c83c37c --- /dev/null +++ b/treepuncher/events/death.py @@ -0,0 +1,4 @@ +from .base import BaseEvent + +class DeathEvent(BaseEvent): + SENTINEL = object() diff --git a/treepuncher/events/join_game.py b/treepuncher/events/join_game.py new file mode 100644 index 0000000..7aee54c --- /dev/null +++ b/treepuncher/events/join_game.py @@ -0,0 +1,10 @@ +from aiocraft.mc.definitions import Dimension, Difficulty, Gamemode + +from .base import BaseEvent + +class JoinGameEvent(BaseEvent): + SENTINEL = object() + + dimension : Dimension + difficulty : Difficulty + gamemode : Gamemode diff --git a/treepuncher/game/__init__.py b/treepuncher/game/__init__.py new file mode 100644 index 0000000..3c47f26 --- /dev/null +++ b/treepuncher/game/__init__.py @@ -0,0 +1,5 @@ +from .state import GameState +from .inventory import GameInventory +from .tablist import GameTablist +from .chat import GameChat +from .world import GameWorld diff --git a/treepuncher/game/chat.py b/treepuncher/game/chat.py new file mode 100644 index 0000000..a2f94d8 --- /dev/null +++ b/treepuncher/game/chat.py @@ -0,0 +1,36 @@ +from typing import Union + +from aiocraft.client import MinecraftClient +from aiocraft.mc.proto.play.clientbound import PacketChat as PacketChatMessage +from aiocraft.mc.proto.play.serverbound import PacketChat + +from ..events.chat import ChatEvent, MessageType + +class GameChat(MinecraftClient): + + def on_chat(self, msg_type:Union[str, MessageType] = None): + if isinstance(msg_type, str): + msg_type = MessageType(msg_type) + def wrapper(fun): + async def process_chat_packet(packet:PacketChatMessage): + msg = ChatEvent(packet.message) + if not msg_type or msg.type == msg_type: + return await fun(msg) + self.register(PacketChatMessage, process_chat_packet) + return fun + return wrapper + + + async def chat(self, message:str, whisper:str=None, wait:bool=False): + if whisper: + message = f"/w {whisper} {message}" + await self.dispatcher.write( + PacketChat( + self.dispatcher.proto, + message=message + ), + wait=wait + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) diff --git a/treepuncher/game/inventory.py b/treepuncher/game/inventory.py new file mode 100644 index 0000000..c6cd160 --- /dev/null +++ b/treepuncher/game/inventory.py @@ -0,0 +1,38 @@ +from typing import List + +from aiocraft.client import MinecraftClient +from aiocraft.mc.definitions import Item +from aiocraft.mc.proto.play.clientbound import PacketSetSlot, PacketHeldItemSlot as PacketHeldItemChange +from aiocraft.mc.proto.play.serverbound import PacketHeldItemSlot + +class GameInventory(MinecraftClient): + slot : int + inventory : List[Item] + # TODO inventory + + async def set_slot(self, slot:int): + self.slot = slot + await self.dispatcher.write(PacketHeldItemSlot(self.dispatcher.proto, slotId=slot)) + + @property + def hotbar(self) -> List[Item]: + return self.inventory[36:45] + + @property + def selected(self) -> Item: + return self.hotbar[self.slot] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.slot = 0 + self.inventory = [ Item() for _ in range(46) ] + + @self.on_packet(PacketSetSlot) + async def on_set_slot(packet:PacketSetSlot): + if packet.windowId == 0: # player inventory + self.inventory[packet.slot] = packet.item + + @self.on_packet(PacketHeldItemChange) + async def on_held_item_change(packet:PacketHeldItemChange): + self.slot = packet.slot diff --git a/treepuncher/game/state.py b/treepuncher/game/state.py new file mode 100644 index 0000000..27b1a9f --- /dev/null +++ b/treepuncher/game/state.py @@ -0,0 +1,132 @@ +import asyncio +import datetime + +from aiocraft.client import MinecraftClient +from aiocraft.mc.definitions import Gamemode, Dimension, Difficulty +from aiocraft.mc.proto import PacketRespawn, PacketLogin, PacketUpdateHealth, PacketExperience, PacketSettings, PacketClientCommand + +from ..events import JoinGameEvent, DeathEvent + +class GameState(MinecraftClient): + hp : float + food : float + xp : float + lvl : int + total_xp : int + + # TODO player abilities + # walk_speed : float + # fly_speed : float + # flags : int + + in_game : bool + gamemode : Gamemode + dimension : Dimension + difficulty : Difficulty + join_time : datetime.datetime + + def on_death(self): + def decorator(fun): + @functool.wraps(fun) + async def wrapper(): + event = DeathEvent() + return await fun(event) + return self.register(DeathEvent.SENTINEL, fun) + return decorator + + def on_joined_world(self): + def decorator(fun): + @functool.wraps(fun) + async def wrapper(): + event = JoinGameEvent() + return await fun(event) + return self.register(JoinGameEvent.SENTINEL, callback) + return decorator + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.in_game = False + self.gamemode = Gamemode.SURVIVAL + self.dimension = Dimension.OVERWORLD + self.difficulty = Difficulty.HARD + self.join_time = datetime.datetime(2011, 11, 18) + + self.hp = 20.0 + self.food = 20.0 + self.xp = 0.0 + self.lvl = 0 + + @self.on_disconnected() + async def disconnected_cb(): + self.in_game = False + + @self.on_packet(PacketRespawn) + async def on_player_respawning(packet:PacketRespawn): + self.gamemode = Gamemode(packet.gamemode) + self.dimension = Dimension(packet.dimension) + self.difficulty = Difficulty(packet.difficulty) + if self.difficulty != Difficulty.PEACEFUL \ + and self.gamemode != Gamemode.SPECTATOR: + self.in_game = True + else: + self.in_game = False + self._logger.info( + "Reloading world: %s (%s) in %s", + self.dimension.name, + self.difficulty.name, + self.gamemode.name + ) + + @self.on_packet(PacketLogin) + async def player_joining_cb(packet:PacketLogin): + self.gamemode = Gamemode(packet.gameMode) + self.dimension = Dimension(packet.dimension) + self.difficulty = Difficulty(packet.difficulty) + self.join_time = datetime.datetime.now() + if self.difficulty != Difficulty.PEACEFUL \ + and self.gamemode != Gamemode.SPECTATOR: + self.in_game = True + else: + self.in_game = False + self._logger.info( + "Joined world: %s (%s) in %s", + self.dimension.name, + self.difficulty.name, + self.gamemode.name + ) + self.run_callbacks(JoinGameEvent, self.dimension, self.difficulty, self.gamemode) + await self.dispatcher.write( + PacketSettings( + self.dispatcher.proto, + locale="en_US", + viewDistance=4, + chatFlags=0, + chatColors=True, + skinParts=0xF, + mainHand=0, + ) + ) + await self.dispatcher.write(PacketClientCommand(self.dispatcher.proto, actionId=0)) + + @self.on_packet(PacketUpdateHealth) + async def player_hp_cb(packet:PacketUpdateHealth): + died = packet.health != self.hp and packet.health <= 0 + self.hp = packet.health + self.food = packet.food + packet.foodSaturation + if died: + self.run_callbacks(DeathEvent.SENTINEL) + self._logger.info("Dead, respawning...") + await asyncio.sleep(0.5) + await self.dispatcher.write( + PacketClientCommand(self.dispatcher.proto, actionId=0) # respawn + ) + + @self.on_packet(PacketExperience) + async def player_xp_cb(packet:PacketExperience): + if packet.level != self.lvl: + self._logger.info("Level up : %d", packet.level) + self.xp = packet.experienceBar + self.lvl = packet.level + self.total_xp = packet.totalExperience + diff --git a/treepuncher/game/tablist.py b/treepuncher/game/tablist.py new file mode 100644 index 0000000..20dfad9 --- /dev/null +++ b/treepuncher/game/tablist.py @@ -0,0 +1,41 @@ +import uuid +import datetime + +from typing import Dict, List + +from aiocraft.client import MinecraftClient +from aiocraft.mc.definitions import Item +from aiocraft.mc.proto import PacketPlayerInfo + +class GameTablist(MinecraftClient): + tablist : Dict[uuid.UUID, dict] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.tablist = {} + + @self.on_connected() + async def connected_cb(): + self.tablist.clear() + + @self.on_packet(PacketPlayerInfo) + async def tablist_update(packet:PacketPlayerInfo): + for record in packet.data: + uid = record['UUID'] + if packet.action != 0 and uid not in self.tablist: + continue # TODO this happens kinda often but doesn't seem to be an issue? + if packet.action == 0: + self.tablist[uid] = record + self.tablist[uid]['joinTime'] = datetime.datetime.now() + elif packet.action == 1: + self.tablist[uid]['gamemode'] = record['gamemode'] + elif packet.action == 2: + self.tablist[uid]['ping'] = record['ping'] + elif packet.action == 3: + self.tablist[uid]['displayName'] = record['displayName'] + elif packet.action == 4: + self.tablist.pop(uid, None) + + + diff --git a/treepuncher/game/world.py b/treepuncher/game/world.py new file mode 100644 index 0000000..906d0e7 --- /dev/null +++ b/treepuncher/game/world.py @@ -0,0 +1,35 @@ +import uuid +import datetime + +from typing import Dict, List + +from aiocraft.client import MinecraftClient +from aiocraft.mc.definitions import BlockPos +from aiocraft.mc.proto import PacketPosition, PacketTeleportConfirm + +class GameWorld(MinecraftClient): + position : BlockPos + # TODO world + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.position = BlockPos(0, 0, 0) + + @self.on_connected() + async def connected_cb(): + self.tablist.clear() + + @self.on_packet(PacketPosition) + async def player_rubberband_cb(packet:PacketPosition): + self.position = BlockPos(packet.x, packet.y, packet.z) + self._logger.info( + "Position synchronized : (x:%.0f,y:%.0f,z:%.0f)", + self.position.x, self.position.y, self.position.z + ) + await self.dispatcher.write( + PacketTeleportConfirm( + self.dispatcher.proto, + teleportId=packet.teleportId + ) + ) diff --git a/treepuncher/helpers.py b/treepuncher/helpers.py new file mode 100644 index 0000000..2f6beab --- /dev/null +++ b/treepuncher/helpers.py @@ -0,0 +1,49 @@ +import logging + +from typing import Dict + +from termcolor import colored + +def configure_logging(name:str, level=logging.INFO, color:bool = True): + import os + from logging.handlers import RotatingFileHandler + + if not os.path.isdir("debug"): + os.mkdir("debug") + + class ColorFormatter(logging.Formatter): + def __init__(self, fmt:str): + self.fmt : str = fmt + self.formatters : Dict[int, logging.Formatter] = { + logging.DEBUG: logging.Formatter(colored(fmt, color='grey')), + logging.INFO: logging.Formatter(colored(fmt)), + logging.WARNING: logging.Formatter(colored(fmt, color='yellow')), + logging.ERROR: logging.Formatter(colored(fmt, color='red')), + logging.CRITICAL: logging.Formatter(colored(fmt, color='red', attrs=['bold'])), + } + + def format(self, record:logging.LogRecord) -> str: + if record.exc_text: # jank way to color the stacktrace but will do for now + record.exc_text = colored(record.exc_text, color='grey', attrs=['bold']) + return self.formatters[record.levelno].format(record) + + logger = logging.getLogger() + logger.setLevel(level) + # create file handler which logs even debug messages + fh = RotatingFileHandler(f'data/{name}.log', maxBytes=1048576, backupCount=5) # 1MB files + fh.setLevel(logging.DEBUG) + # create console handler with a higher log level + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + # create formatter and add it to the handlers + file_formatter = logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", "%b %d %Y %H:%M:%S") + print_formatter : logging.Formatter + if color: + print_formatter = ColorFormatter("├🮥 %(message)s") + else: + print_formatter = logging.Formatter("├🮥 %(message)s") + fh.setFormatter(file_formatter) + ch.setFormatter(print_formatter) + # add the handlers to the logger + logger.addHandler(fh) + logger.addHandler(ch) diff --git a/treepuncher/modules/__init__.py b/treepuncher/modules/__init__.py deleted file mode 100644 index cb801d8..0000000 --- a/treepuncher/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .module import LogicModule diff --git a/treepuncher/modules/core.py b/treepuncher/modules/core.py deleted file mode 100644 index 7492eb6..0000000 --- a/treepuncher/modules/core.py +++ /dev/null @@ -1,81 +0,0 @@ -from ..treepuncher import Treepuncher, TreepuncherEvents -from .module import LogicModule - -from aiocraft.mc.proto.play.clientbound import ( - PacketRespawn, PacketLogin, PacketPosition, PacketUpdateHealth, PacketExperience, - PacketAbilities, PacketChat as PacketChatMessage -) -from aiocraft.mc.proto.play.serverbound import PacketTeleportConfirm, PacketClientCommand, PacketChat -from aiocraft.mc.definitions import Difficulty, Dimension, Gamemode, Position - -class CoreLogic(LogicModule): - def register(self, client:Treepuncher): - @client.on_disconnected() - async def on_disconnected(): - client.in_game = False - - @client.on_packet(PacketRespawn) - async def on_player_respawning(packet:PacketRespawn): - client.gamemode = Gamemode(packet.gamemode) - client.dimension = Dimension(packet.dimension) - client.difficulty = Difficulty(packet.difficulty) - if client.difficulty != Difficulty.PEACEFUL \ - and client.gamemode != Gamemode.SPECTATOR: - client.in_game = True - else: - client.in_game = False - client._logger.info( - "Reloading world: %s (%s) in %s", - client.dimension.name, - client.difficulty.name, - client.gamemode.name - ) - - @client.on_packet(PacketLogin) - async def player_joining_cb(packet:PacketLogin): - client.gamemode = Gamemode(packet.gameMode) - client.dimension = Dimension(packet.dimension) - client.difficulty = Difficulty(packet.difficulty) - if client.difficulty != Difficulty.PEACEFUL \ - and client.gamemode != Gamemode.SPECTATOR: - client.in_game = True - else: - client.in_game = False - client._logger.info( - "Joined world: %s (%s) in %s", - client.dimension.name, - client.difficulty.name, - client.gamemode.name - ) - client.run_callbacks(TreepuncherEvents.IN_GAME) - - @client.on_packet(PacketPosition) - async def player_rubberband_cb(packet:PacketPosition): - client._logger.info("Position synchronized") - client.position = Position(packet.x, packet.y, packet.z) - await client.dispatcher.write( - PacketTeleportConfirm( - client.dispatcher.proto, - teleportId=packet.teleportId - ) - ) - - @client.on_packet(PacketUpdateHealth) - async def player_hp_cb(packet:PacketUpdateHealth): - if packet.health != client.hp and packet.health <= 0: - client._logger.info("Dead, respawning...") - await client.dispatcher.write( - PacketClientCommand(client.dispatcher.proto, actionId=0) # respawn - ) - client.run_callbacks(TreepuncherEvents.DIED) - client.hp = packet.health - client.food = packet.food - - @client.on_packet(PacketExperience) - async def player_xp_cb(packet:PacketExperience): - if packet.level != client.lvl: - client._logger.info("Level up : %d", packet.level) - client.xp = packet.experienceBar - client.lvl = packet.level - client.total_xp = packet.totalExperience - diff --git a/treepuncher/modules/module.py b/treepuncher/modules/module.py deleted file mode 100644 index 5c2110a..0000000 --- a/treepuncher/modules/module.py +++ /dev/null @@ -1,11 +0,0 @@ - -class LogicModule: - def register(self, client:'Treepuncher') -> None: - pass # override to register callbacks on client - - async def initialize(self, client:'Treepuncher') -> None: - pass # override to register stuff on client start - - async def cleanup(self, client:'Treepuncher') -> None: - pass # override to register stuff on client stop - diff --git a/treepuncher/storage.py b/treepuncher/storage.py new file mode 100644 index 0000000..c3f39f9 --- /dev/null +++ b/treepuncher/storage.py @@ -0,0 +1,60 @@ +import json +import sqlite3 + +from dataclasses import dataclass +from typing import Optional, Any + +@dataclass +class SystemState: + name : str + token : str + start_time : int + +class Storage: + name : str + db : sqlite3.Connection + + def __init__(self, name:str): + self.name = name + self.db = sqlite3.connect(f'{name}.session') + + def __del__(self): + self.close() + + def close(self) -> None: + self.db.close() + + def _init_db(self): + cur = self.db.cursor() + cur.execute('CREATE TABLE system (name TEXT, token TEXT, start_time LONG)') + cur.execute('CREATE TABLE documents (name TEXT, value TEXT)') + self.db.commit() + + def _set_state(self, state:SystemState): + cur = self.db.cursor() + cur.execute('DELETE FROM system') + cur.execute('INSERT INTO system VALUES (?, ?, ?)', (state.name, state.token, state.start_time)) + self.db.commit() + + def system(self) -> SystemState: + cur = self.db.cursor() + val = cur.execute('SELECT * FROM system').fetchall() + if not val: + raise ValueError("No system state set") + return SystemState( + name=val[0][0], + token=val[0][1], + start_time=val[0][2] + ) + + def get(self, key:str) -> Optional[Any]: + cur = self.db.cursor() + val = cur.execute("SELECT * FROM documents WHERE name = ?", (key,)).fetchall() + return json.loads(val[0][1]) if val else None + + def put(self, key:str, val:Any) -> None: + cur = self.db.cursor() + cur.execute("DELETE FROM documents WHERE name = ?", (key,)) + cur.execute("INSERT INTO documents VALUES (?, ?)", (key, json.dumps(val))) + self.db.commit() + diff --git a/treepuncher/treepuncher.py b/treepuncher/treepuncher.py index 9697965..952f560 100644 --- a/treepuncher/treepuncher.py +++ b/treepuncher/treepuncher.py @@ -4,113 +4,108 @@ import asyncio import datetime import uuid -from typing import List, Dict, Union, Optional, Any, Type +from typing import List, Dict, Tuple, Union, Optional, Any, Type, get_type_hints from enum import Enum +from dataclasses import dataclass +from configparser import ConfigParser from apscheduler.schedulers.asyncio import AsyncIOScheduler from aiocraft.client import MinecraftClient from aiocraft.mc.packet import Packet -from aiocraft.mc.definitions import Difficulty, Dimension, Gamemode, BlockPos, Item - -from aiocraft.mc.proto.play.clientbound import ( - PacketRespawn, PacketLogin, PacketPosition, PacketUpdateHealth, PacketExperience, PacketSetSlot, - PacketAbilities, PacketPlayerInfo, PacketChat as PacketChatMessage, PacketHeldItemSlot as PacketHeldItemChange -) -from aiocraft.mc.proto.play.serverbound import ( - PacketTeleportConfirm, PacketClientCommand, PacketSettings, PacketChat, - PacketHeldItemSlot -) +from .storage import Storage from .notifier import Notifier -from .events import ChatEvent -from .events.chat import MessageType -from .modules.module import LogicModule +from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]") -class TreepuncherEvents(Enum): - DIED = 0 - IN_GAME = 1 +class ConfigObject: + def __getitem__(self, key:str) -> Any: + return getattr(self, key) -class Treepuncher(MinecraftClient): - in_game : bool - gamemode : Gamemode - dimension : Dimension - difficulty : Difficulty - join_time : datetime.datetime +class Addon: + name : str + config : ConfigObject + _client : 'Treepuncher' - hp : float - food : float - xp : float - lvl : int - total_xp : int + @dataclass(frozen=True) + class Options(ConfigObject): + pass - slot : int - inventory : List[Item] - # TODO inventory + @property + def client(self) -> 'Treepuncher': + return self._client - position : BlockPos - # TODO world + def __init__(self, client:'Treepuncher'): + self._client = client + self.name = type(self).__name__ + cfg = self._client.config + kwargs : Dict[str, Any] = {} + for name, clazz in get_type_hints(self.Options).items(): + default = getattr(self.Options, name, None) + if cfg.has_option(self.name, name): + if clazz is bool: + kwargs[name] = self._client.config[self.name].getboolean(name) + else: + kwargs[name] = clazz(self._client.config[self.name].get(name)) + elif default is None: + raise ValueError(f"Missing required value '{name}' of type '{clazz.__name__}' in section '{self.name}'") + else: # not really necessary since it's a dataclass but whatever + kwargs[name] = default + self.config = self.Options(**kwargs) - tablist : Dict[uuid.UUID, dict] + async def initialize(self): + pass - # TODO player abilities - # walk_speed : float - # fly_speed : float - # flags : int + async def cleanup(self): + pass + +class Treepuncher( + GameState, + GameChat, + GameInventory, + GameTablist, + GameWorld +): + name : str + config : ConfigParser + storage : Storage notifier : Notifier scheduler : AsyncIOScheduler - modules : List[LogicModule] + modules : List[Addon] ctx : Dict[Any, Any] - def __init__(self, *args, notifier:Notifier=None, **kwargs): + def __init__(self, name:str, *args, config_file:str=None, **kwargs): super().__init__(*args, **kwargs) self.ctx = dict() - self.in_game = False - self.gamemode = Gamemode.SURVIVAL - self.dimension = Dimension.OVERWORLD - self.difficulty = Difficulty.HARD - self.join_time = datetime.datetime(2011, 11, 18) + self.name = name + self.config = ConfigParser() + config_path = config_file or f'config-{self.name}.ini' + self.config.read(config_path) - self.hp = 20.0 - self.food = 20.0 - self.xp = 0.0 - self.lvl = 0 + self.storage = Storage(self.name) - self.slot = 0 - self.inventory = [ Item() for _ in range(46) ] - - self.position = BlockPos(0, 0, 0) - - self.tablist = {} - - self._register_handlers() self.modules = [] - self.notifier = notifier or Notifier() + # self.notifier = notifier or Notifier() tz = datetime.datetime.now(datetime.timezone.utc).astimezone().tzname() # APScheduler will complain if I don't specify a timezone... self.scheduler = AsyncIOScheduler(timezone=tz) logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy self.scheduler.start(paused=True) @property - def name(self) -> str: - if self.online_mode and self.token: - return self.token.selectedProfile.name - if not self.online_mode and self.username: - return self.username - raise ValueError("No token or username given") - - @property - def hotbar(self) -> List[Item]: - return self.inventory[36:45] - - @property - def selected(self) -> Item: - return self.hotbar[self.slot] + def playerName(self) -> str: + if self.online_mode: + if self._authenticator and self._authenticator.selectedProfile: + return self._authenticator.selectedProfile.name + raise ValueError("Username unknown: client not authenticated") + else: + if self._username: + return self._username + raise ValueError("No username configured for offline mode") async def start(self): await self.notifier.initialize(self) @@ -123,170 +118,13 @@ class Treepuncher(MinecraftClient): self.scheduler.pause() await super().stop(force=force) for m in self.modules: - await m.cleanup(self) + await m.cleanup() await self.notifier.cleanup(self) - def add(self, module:LogicModule): - module.register(self) - self.modules.append(module) - - def on_chat(self, msg_type:Union[str, MessageType] = None): - if isinstance(msg_type, str): - msg_type = MessageType(msg_type) - def wrapper(fun): - async def process_chat_packet(packet:PacketChatMessage): - msg = ChatEvent(packet.message) - if not msg_type or msg.type == msg_type: - return await fun(msg) - self.register(PacketChatMessage, process_chat_packet) - return fun - return wrapper - - def on_death(self): - def wrapper(fun): - return self.register(TreepuncherEvents.DIED, fun) - return wrapper - - def on_joined_world(self): - def wrapper(fun): - return self.register(TreepuncherEvents.IN_GAME, fun) - return wrapper + def install(self, module:Type[Addon]) -> Type[Addon]: + self.modules.append(module(self)) + return module async def write(self, packet:Packet, wait:bool=False): await self.dispatcher.write(packet, wait) - async def chat(self, message:str, whisper:str=None, wait:bool=False): - if whisper: - message = f"/w {whisper} {message}" - await self.dispatcher.write( - PacketChat( - self.dispatcher.proto, - message=message - ), - wait=wait - ) - - async def set_slot(self, slot:int): - self.slot = slot - await self.dispatcher.write(PacketHeldItemSlot(self.dispatcher.proto, slotId=slot)) - - def _register_handlers(self): - @self.on_disconnected() - async def disconnected_cb(): - self.in_game = False - - @self.on_connected() - async def connected_cb(): - self.tablist.clear() - - @self.on_packet(PacketSetSlot) - async def on_set_slot(packet:PacketSetSlot): - if packet.windowId == 0: # player inventory - self.inventory[packet.slot] = packet.item - - @self.on_packet(PacketHeldItemChange) - async def on_held_item_change(packet:PacketHeldItemChange): - self.slot = packet.slot - - @self.on_packet(PacketRespawn) - async def on_player_respawning(packet:PacketRespawn): - self.gamemode = Gamemode(packet.gamemode) - self.dimension = Dimension(packet.dimension) - self.difficulty = Difficulty(packet.difficulty) - if self.difficulty != Difficulty.PEACEFUL \ - and self.gamemode != Gamemode.SPECTATOR: - self.in_game = True - else: - self.in_game = False - self._logger.info( - "Reloading world: %s (%s) in %s", - self.dimension.name, - self.difficulty.name, - self.gamemode.name - ) - - @self.on_packet(PacketLogin) - async def player_joining_cb(packet:PacketLogin): - self.gamemode = Gamemode(packet.gameMode) - self.dimension = Dimension(packet.dimension) - self.difficulty = Difficulty(packet.difficulty) - self.join_time = datetime.datetime.now() - if self.difficulty != Difficulty.PEACEFUL \ - and self.gamemode != Gamemode.SPECTATOR: - self.in_game = True - else: - self.in_game = False - self._logger.info( - "Joined world: %s (%s) in %s", - self.dimension.name, - self.difficulty.name, - self.gamemode.name - ) - self.run_callbacks(TreepuncherEvents.IN_GAME) - await self.write( - PacketSettings( - self.dispatcher.proto, - locale="en_US", - viewDistance=4, - chatFlags=0, - chatColors=True, - skinParts=0xF, - mainHand=0, - ) - ) - await self.write(PacketClientCommand(self.dispatcher.proto, actionId=0)) - - @self.on_packet(PacketPosition) - async def player_rubberband_cb(packet:PacketPosition): - self.position = BlockPos(packet.x, packet.y, packet.z) - self._logger.info( - "Position synchronized : (x:%.0f,y:%.0f,z:%.0f)", - self.position.x, self.position.y, self.position.z - ) - await self.dispatcher.write( - PacketTeleportConfirm( - self.dispatcher.proto, - teleportId=packet.teleportId - ) - ) - - @self.on_packet(PacketUpdateHealth) - async def player_hp_cb(packet:PacketUpdateHealth): - died = packet.health != self.hp and packet.health <= 0 - self.hp = packet.health - self.food = packet.food + packet.foodSaturation - if died: - self.run_callbacks(TreepuncherEvents.DIED) - self._logger.info("Dead, respawning...") - await asyncio.sleep(0.5) - await self.dispatcher.write( - PacketClientCommand(self.dispatcher.proto, actionId=0) # respawn - ) - - @self.on_packet(PacketExperience) - async def player_xp_cb(packet:PacketExperience): - if packet.level != self.lvl: - self._logger.info("Level up : %d", packet.level) - self.xp = packet.experienceBar - self.lvl = packet.level - self.total_xp = packet.totalExperience - - @self.on_packet(PacketPlayerInfo) - async def tablist_update(packet:PacketPlayerInfo): - for record in packet.data: - uid = record['UUID'] - if packet.action != 0 and uid not in self.tablist: - continue # TODO this happens kinda often but doesn't seem to be an issue? - if packet.action == 0: - self.tablist[uid] = record - self.tablist[uid]['joinTime'] = datetime.datetime.now() - elif packet.action == 1: - self.tablist[uid]['gamemode'] = record['gamemode'] - elif packet.action == 2: - self.tablist[uid]['ping'] = record['ping'] - elif packet.action == 3: - self.tablist[uid]['displayName'] = record['displayName'] - elif packet.action == 4: - self.tablist.pop(uid, None) - -