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:
parent
e20141f437
commit
4b1b508be9
19 changed files with 624 additions and 327 deletions
|
@ -1,2 +1,5 @@
|
|||
setproctitle
|
||||
termcolor
|
||||
apscheduler
|
||||
aiocraft
|
||||
aioconsole
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
@ -1 +1,3 @@
|
|||
from .chat import ChatEvent
|
||||
from .join_game import JoinGameEvent
|
||||
from .death import DeathEvent
|
||||
|
|
2
treepuncher/events/base.py
Normal file
2
treepuncher/events/base.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
class BaseEvent:
|
||||
pass
|
|
@ -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<usr>[A-Za-z0-9_]+)> (?P<msg>.+)")
|
||||
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)
|
||||
|
@ -19,7 +21,7 @@ class MessageType(Enum):
|
|||
LEAVE = "leave"
|
||||
SYSTEM = "system"
|
||||
|
||||
class ChatEvent:
|
||||
class ChatEvent(BaseEvent):
|
||||
text : str
|
||||
type : MessageType
|
||||
user : str
|
||||
|
|
4
treepuncher/events/death.py
Normal file
4
treepuncher/events/death.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from .base import BaseEvent
|
||||
|
||||
class DeathEvent(BaseEvent):
|
||||
SENTINEL = object()
|
10
treepuncher/events/join_game.py
Normal file
10
treepuncher/events/join_game.py
Normal 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
|
5
treepuncher/game/__init__.py
Normal file
5
treepuncher/game/__init__.py
Normal 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
36
treepuncher/game/chat.py
Normal 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)
|
38
treepuncher/game/inventory.py
Normal file
38
treepuncher/game/inventory.py
Normal 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
132
treepuncher/game/state.py
Normal 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
|
||||
|
41
treepuncher/game/tablist.py
Normal file
41
treepuncher/game/tablist.py
Normal 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
35
treepuncher/game/world.py
Normal 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
49
treepuncher/helpers.py
Normal 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)
|
|
@ -1 +0,0 @@
|
|||
from .module import LogicModule
|
|
@ -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
|
||||
|
|
@ -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
60
treepuncher/storage.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue