restructured treepuncher, reworked addons system

Moved most of Treepuncher logic into 'game/*' files, making it more
modular. Changed a little bit events (but will need much more work).
Added SQLite storage and ini config file, for persistance. Moved the
launcher inside __main__, making it a launchable python module.

There still is some confusion between Addons and game logic components.
This commit is contained in:
əlemi 2022-02-15 16:53:53 +01:00
parent e20141f437
commit 4b1b508be9
No known key found for this signature in database
GPG key ID: BBCBFE5D7244634E
19 changed files with 624 additions and 327 deletions

View file

@ -1,2 +1,5 @@
setproctitle
termcolor
apscheduler apscheduler
aiocraft aiocraft
aioconsole

View file

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

View file

@ -1 +1,3 @@
from .chat import ChatEvent from .chat import ChatEvent
from .join_game import JoinGameEvent
from .death import DeathEvent

View file

@ -0,0 +1,2 @@
class BaseEvent:
pass

View file

@ -5,6 +5,8 @@ from enum import Enum
from aiocraft.util.helpers import parse_chat from aiocraft.util.helpers import parse_chat
from .base import BaseEvent
CHAT_MESSAGE_MATCHER = re.compile(r"<(?P<usr>[A-Za-z0-9_]+)> (?P<msg>.+)") CHAT_MESSAGE_MATCHER = re.compile(r"<(?P<usr>[A-Za-z0-9_]+)> (?P<msg>.+)")
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]") REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
WHISPER_MATCHER = re.compile(r"(?:to (?P<touser>[A-Za-z0-9_]+)( |):|(?P<fromuser>[A-Za-z0-9_]+) whispers( |):|from (?P<from9b>[A-Za-z0-9_]+):) (?P<txt>.+)", flags=re.IGNORECASE) WHISPER_MATCHER = re.compile(r"(?:to (?P<touser>[A-Za-z0-9_]+)( |):|(?P<fromuser>[A-Za-z0-9_]+) whispers( |):|from (?P<from9b>[A-Za-z0-9_]+):) (?P<txt>.+)", flags=re.IGNORECASE)
@ -19,7 +21,7 @@ class MessageType(Enum):
LEAVE = "leave" LEAVE = "leave"
SYSTEM = "system" SYSTEM = "system"
class ChatEvent: class ChatEvent(BaseEvent):
text : str text : str
type : MessageType type : MessageType
user : str user : str

View file

@ -0,0 +1,4 @@
from .base import BaseEvent
class DeathEvent(BaseEvent):
SENTINEL = object()

View file

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

View file

@ -0,0 +1,5 @@
from .state import GameState
from .inventory import GameInventory
from .tablist import GameTablist
from .chat import GameChat
from .world import GameWorld

36
treepuncher/game/chat.py Normal file
View file

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

View file

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

132
treepuncher/game/state.py Normal file
View file

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

View file

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

35
treepuncher/game/world.py Normal file
View file

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

49
treepuncher/helpers.py Normal file
View file

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

View file

@ -1 +0,0 @@
from .module import LogicModule

View file

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

View file

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

60
treepuncher/storage.py Normal file
View file

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

View file

@ -4,113 +4,108 @@ import asyncio
import datetime import datetime
import uuid 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 enum import Enum
from dataclasses import dataclass
from configparser import ConfigParser
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from aiocraft.client import MinecraftClient from aiocraft.client import MinecraftClient
from aiocraft.mc.packet import Packet 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 .notifier import Notifier
from .events import ChatEvent from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld
from .events.chat import MessageType
from .modules.module import LogicModule
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]") REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
class TreepuncherEvents(Enum): class ConfigObject:
DIED = 0 def __getitem__(self, key:str) -> Any:
IN_GAME = 1 return getattr(self, key)
class Treepuncher(MinecraftClient): class Addon:
in_game : bool name : str
gamemode : Gamemode config : ConfigObject
dimension : Dimension _client : 'Treepuncher'
difficulty : Difficulty
join_time : datetime.datetime
hp : float @dataclass(frozen=True)
food : float class Options(ConfigObject):
xp : float pass
lvl : int
total_xp : int
slot : int @property
inventory : List[Item] def client(self) -> 'Treepuncher':
# TODO inventory return self._client
position : BlockPos def __init__(self, client:'Treepuncher'):
# TODO world 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 async def cleanup(self):
# walk_speed : float pass
# fly_speed : float
# flags : int class Treepuncher(
GameState,
GameChat,
GameInventory,
GameTablist,
GameWorld
):
name : str
config : ConfigParser
storage : Storage
notifier : Notifier notifier : Notifier
scheduler : AsyncIOScheduler scheduler : AsyncIOScheduler
modules : List[LogicModule] modules : List[Addon]
ctx : Dict[Any, Any] 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) super().__init__(*args, **kwargs)
self.ctx = dict() self.ctx = dict()
self.in_game = False self.name = name
self.gamemode = Gamemode.SURVIVAL self.config = ConfigParser()
self.dimension = Dimension.OVERWORLD config_path = config_file or f'config-{self.name}.ini'
self.difficulty = Difficulty.HARD self.config.read(config_path)
self.join_time = datetime.datetime(2011, 11, 18)
self.hp = 20.0 self.storage = Storage(self.name)
self.food = 20.0
self.xp = 0.0
self.lvl = 0
self.slot = 0
self.inventory = [ Item() for _ in range(46) ]
self.position = BlockPos(0, 0, 0)
self.tablist = {}
self._register_handlers()
self.modules = [] 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... tz = datetime.datetime.now(datetime.timezone.utc).astimezone().tzname() # APScheduler will complain if I don't specify a timezone...
self.scheduler = AsyncIOScheduler(timezone=tz) self.scheduler = AsyncIOScheduler(timezone=tz)
logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy
self.scheduler.start(paused=True) self.scheduler.start(paused=True)
@property @property
def name(self) -> str: def playerName(self) -> str:
if self.online_mode and self.token: if self.online_mode:
return self.token.selectedProfile.name if self._authenticator and self._authenticator.selectedProfile:
if not self.online_mode and self.username: return self._authenticator.selectedProfile.name
return self.username raise ValueError("Username unknown: client not authenticated")
raise ValueError("No token or username given") else:
if self._username:
@property return self._username
def hotbar(self) -> List[Item]: raise ValueError("No username configured for offline mode")
return self.inventory[36:45]
@property
def selected(self) -> Item:
return self.hotbar[self.slot]
async def start(self): async def start(self):
await self.notifier.initialize(self) await self.notifier.initialize(self)
@ -123,170 +118,13 @@ class Treepuncher(MinecraftClient):
self.scheduler.pause() self.scheduler.pause()
await super().stop(force=force) await super().stop(force=force)
for m in self.modules: for m in self.modules:
await m.cleanup(self) await m.cleanup()
await self.notifier.cleanup(self) await self.notifier.cleanup(self)
def add(self, module:LogicModule): def install(self, module:Type[Addon]) -> Type[Addon]:
module.register(self) self.modules.append(module(self))
self.modules.append(module) return 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
async def write(self, packet:Packet, wait:bool=False): async def write(self, packet:Packet, wait:bool=False):
await self.dispatcher.write(packet, wait) 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)