Compare commits
No commits in common. "dev" and "main" have entirely different histories.
43 changed files with 442 additions and 1598 deletions
|
@ -1,10 +0,0 @@
|
|||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -127,6 +127,3 @@ dmypy.json
|
|||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# Auto generated version file
|
||||
src/treepuncher/__version__.py
|
||||
|
|
111
README.md
111
README.md
|
@ -1,111 +1,2 @@
|
|||
# treepuncher
|
||||
an hackable headless Minecraft client, built with **[aiocraft](https://git.alemi.dev/aiocraft.git/about)**
|
||||
|
||||
### Features
|
||||
* persistent storage
|
||||
* configuration file
|
||||
* pluggable plugin system
|
||||
* event system with callbacks
|
||||
* world processing
|
||||
|
||||
## Quick Start
|
||||
`treepuncher` is still in development and thus not available yet on PyPI, to install it fetch directly from git:
|
||||
* `pip install "git+https://git.alemi.dev/treepuncher.git@v0.3.0"`
|
||||
|
||||
currently only 1.16.5 is being targeted, so while simple things _should_ work on any version, more complex interactions may break outside 1.16.5
|
||||
|
||||
`treepuncher` can both be run as a pluggable CLI application or as a library, depending on how much you need to customize its behaviour
|
||||
|
||||
### as an application
|
||||
`treepuncher` ships as a standalone CLI application which you can run with `python -m treepuncher`
|
||||
|
||||
* prepare a runtime directory with this layout:
|
||||
```
|
||||
.
|
||||
|- log/ # will contain rotating log files: MYBOT.log, MYBOT.log.1 ...
|
||||
|- data/ # will contain session files: MYBOT.session
|
||||
|- addons/ # put your addons here
|
||||
|- MYBOT.ini # your configuration file for one session
|
||||
```
|
||||
|
||||
* create your first addon (for example, a simple chat logger) inside `./addons/chat_logger.py`
|
||||
```py
|
||||
from dataclasses import dataclass
|
||||
from treepuncher import Addon, ConfigObject
|
||||
from treepuncher.events import ChatEvent
|
||||
|
||||
class ChatLogger(Addon):
|
||||
@dataclass # must be a dataclass
|
||||
class Options(ConfigObject): # must extend ConfigObject
|
||||
prefix : str = ""
|
||||
config : Options # must add this type annotation
|
||||
|
||||
def register(self): # register all callbacks and schedulers in here
|
||||
@self.client.on(ChatEvent)
|
||||
async def print_chat(event: ChatEvent):
|
||||
print(f"{event.user} >> {event.text})
|
||||
```
|
||||
* create a config file for your session (for example, `MYBOT`): `MYBOT.ini`
|
||||
```ini
|
||||
[Treepuncher]
|
||||
server = your.server.com
|
||||
username = your_account_username
|
||||
client_id = your_microsoft_authenticator_client_id
|
||||
client_secret = your_microsoft_authenticator_client_secret
|
||||
code = microsoft_auth_code
|
||||
|
||||
; you must specify the addon section to have it loaded,
|
||||
; even if it doesn't take any config value
|
||||
[ChatLogger]
|
||||
prefix = CHAT |::
|
||||
```
|
||||
* run the treepuncher client : `python -m treepuncher MYBOT` (note that session name must be same as config file, minus `.ini`)
|
||||
|
||||
### as a library
|
||||
under the hood `treepuncher` is just a library and it's possible to invoke it programmatically
|
||||
* instantiate the `treepuncher` object
|
||||
```py
|
||||
from treepuncher import Treepuncher
|
||||
|
||||
client = Treepuncher(
|
||||
"my_bot",
|
||||
server="your.server.com",
|
||||
)
|
||||
```
|
||||
* prepare your addons (must extend `treepuncher.Addon`) and install them
|
||||
```py
|
||||
from treepuncher import Addon
|
||||
|
||||
class MyAddon(Addon):
|
||||
pass
|
||||
|
||||
addon = MyAddon()
|
||||
client.install(addon)
|
||||
```
|
||||
* run your client
|
||||
```py
|
||||
client.run()
|
||||
```
|
||||
|
||||
## Authentication
|
||||
`treepuncher` supports both legacy Yggdrasil authentication (with options to override session and auth server) and modern Microsoft OAuth authentication. It will store the auth token inside a session file, to restart without requiring credentials again
|
||||
|
||||
to be able to use Microsoft authentication you will need to register an Azure application (see [community](https://wiki.vg/Microsoft_Authentication_Scheme) and [microsoft](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) docs on how to do that).
|
||||
|
||||
this is a tedious process but can be done just once for many accounts, sadly Microsoft decided that only kids play minecraft and we developers should just suffer...
|
||||
|
||||
**be warned that Microsoft may limit your account if they find your activity suspicious**
|
||||
|
||||
once you have your `client_id` and `client_secret` use [this page](https://fantabos.co/msauth) to generate a login code: put in your `client_id` and any state and press `auth`.
|
||||
you will be brought to Microsoft login page, input your credentials, authorize your application and you will be redirected back to the `msauth` page, but now there should be a code in the `auth code` field
|
||||
|
||||
put this code in your config and you're good to go!
|
||||
|
||||
if you'd rather use classic Yggdrasil authentication, consider [ftbsc yggdrasil](https://yggdrasil.fantabos.co) ([src](https://git.fantabos.co/yggdrasil))
|
||||
|
||||
legacy Yggdrasil authentication supports both an hardcoded password or a pre-authorized access token
|
||||
|
||||
|
||||
## Contributing
|
||||
development is managed by [ftbsc](https://fantabos.co), mostly on [our git](https://git.fantabos.co). If you'd like to contribute, get in contact with any of us using any available channel!
|
||||
|
||||
An hackable Minecraft client, built with aiocraft
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
[build-system]
|
||||
requires = ["setuptools", "setuptools-scm"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "treepuncher"
|
||||
authors = [
|
||||
{name = "alemi", email = "me@alemi.dev"},
|
||||
]
|
||||
description = "An hackable Minecraft client, built with aiocraft"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.7"
|
||||
keywords = ["minecraft", "client", "bot", "hackable"]
|
||||
# license = {text = "MIT"}
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
dependencies = [
|
||||
"setproctitle",
|
||||
"termcolor",
|
||||
"apscheduler",
|
||||
"aioconsole",
|
||||
"aiocraft @ git+https://git.fantabos.co/alemi/aiocraft.git@v0.3.0",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
[tool.setuptools_scm]
|
||||
write_to = "src/treepuncher/__version__.py"
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
apscheduler
|
||||
aiocraft
|
|
@ -1,21 +0,0 @@
|
|||
import sqlite3
|
||||
|
||||
def migrate_old_documents_to_namespaced_documents(db:str):
|
||||
db = sqlite3.connect(db)
|
||||
|
||||
values = db.cursor().execute("SELECT * FROM documents", ()).fetchall();
|
||||
|
||||
for k,v in values:
|
||||
if "_" in k:
|
||||
addon, key = k.split("_", 1)
|
||||
db.cursor().execute("CREATE TABLE IF NOT EXISTS documents_{addon} (name TEXT PRIMARY KEY, value TEXT)", ())
|
||||
db.cursor().execute("INSERT INTO documents_{addon} VALUES (?, ?)", (key, v))
|
||||
db.cursor().execute("DELETE FROM documents WHERE name = ?", k)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) < 2:
|
||||
print("[!] No argument given")
|
||||
exit(-1)
|
||||
migrate_old_documents_to_namespaced_documents(sys.argv[1])
|
||||
|
27
setup.py
Normal file
27
setup.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from setuptools import setup, find_packages
|
||||
|
||||
with open("requirements.txt") as f:
|
||||
requirements = f.read().split("\n")
|
||||
|
||||
setup(
|
||||
name='treepuncher',
|
||||
version='0.0.2',
|
||||
description='An hackable Minecraft client, built with aiocraft',
|
||||
url='https://github.com/alemidev/treepuncher',
|
||||
author='alemi',
|
||||
author_email='me@alemi.dev',
|
||||
license='MIT',
|
||||
packages=find_packages(),
|
||||
package_data = {
|
||||
'treepuncher': ['py.typed'],
|
||||
},
|
||||
install_requires=requirements,
|
||||
classifiers=[
|
||||
'Development Status :: 1 - Planning',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Operating System :: POSIX :: Linux',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
],
|
||||
)
|
|
@ -1,4 +0,0 @@
|
|||
from .scaffold import ConfigObject
|
||||
from .treepuncher import Treepuncher
|
||||
from .addon import Addon
|
||||
from .notifier import Notifier, Provider
|
|
@ -1,130 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
import os
|
||||
import logging
|
||||
import argparse
|
||||
import inspect
|
||||
|
||||
from pathlib import Path
|
||||
from importlib import import_module
|
||||
import traceback
|
||||
from typing import Type, Set, get_type_hints
|
||||
from dataclasses import MISSING, fields
|
||||
|
||||
from setproctitle import setproctitle
|
||||
|
||||
from .treepuncher import Treepuncher, MissingParameterError, Addon, Provider
|
||||
from .scaffold import ConfigObject
|
||||
from .helpers import configure_logging
|
||||
|
||||
def main():
|
||||
# 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
|
||||
#root = Path(os.getcwd())
|
||||
#addon_path = Path(args.path) if args.addon_path else ( root/'addons' )
|
||||
addon_path = Path('addons')
|
||||
addons : Set[Type[Addon]] = set()
|
||||
|
||||
for path in sorted(addon_path.rglob('*.py')):
|
||||
py_path = str(path).replace('/', '.').replace('\\', '.').replace('.py', '')
|
||||
try:
|
||||
m = import_module(py_path)
|
||||
for obj_name in vars(m).keys():
|
||||
obj = getattr(m, obj_name)
|
||||
if obj != Addon and inspect.isclass(obj) and issubclass(obj, Addon):
|
||||
addons.add(obj)
|
||||
except Exception:
|
||||
print(f"Exception importing addon {py_path}")
|
||||
traceback.print_exc()
|
||||
pass
|
||||
|
||||
help_text = '\n\naddons (enabled via config file):'
|
||||
|
||||
for addon in addons:
|
||||
help_text += f"\n {addon.__name__} \t{addon.__doc__ or ''}"
|
||||
cfg_clazz = get_type_hints(addon, localns={'Treepuncher':Treepuncher})['config']
|
||||
if cfg_clazz is ConfigObject:
|
||||
continue # it's the superclass type hint
|
||||
for field in fields(cfg_clazz):
|
||||
default = field.default if field.default is not MISSING \
|
||||
else field.default_factory() if field.default_factory is not MISSING \
|
||||
else MISSING
|
||||
repr_type = field.type.__name__ if isinstance(field.type, type) else str(field.type) # TODO fix for 3.8 I think?
|
||||
help_text += f"\n * {field.name} ({repr_type}) | {'-required-' if default is MISSING else f'{default}'}"
|
||||
help_text += '\n'
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog='python -m treepuncher',
|
||||
description='Treepuncher | Block Game automation framework',
|
||||
epilog=help_text, # TODO maybe build this afterwards?
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument('name', help='name to use for this client session')
|
||||
|
||||
parser.add_argument('--server', dest='server', default='', help='server to connect to')
|
||||
parser.add_argument('--debug', dest='_debug', action='store_const', const=True, default=False, help="enable debug logs")
|
||||
parser.add_argument('--no-packet-filter', dest='use_packet_whitelist', action='store_const', const=False, default=True, help="disable packet whitelist, will decrease performance")
|
||||
|
||||
parser.add_argument('--offline', dest='offline', action='store_const', const=True, default=False, help="run client in offline mode")
|
||||
|
||||
parser.add_argument('--code', dest='code', default='', help='login code for oauth2 flow')
|
||||
|
||||
parser.add_argument('--mojang', dest='mojang', action='store_const', const=True, default=False, help="use legacy Mojang authenticator")
|
||||
parser.add_argument('--print-token', dest='print_token', action='store_const', const=True, default=False, help="show legacy token before stopping")
|
||||
|
||||
parser.add_argument('--addons', dest='add', metavar="A", nargs='+', type=str, default=None, help='specify addons to enable, defaults to all')
|
||||
# parser.add_argument('--addon-path', dest='path', default='', help='path for loading addons') # TODO make this possible
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
configure_logging(args.name, level=logging.DEBUG if args._debug else logging.INFO)
|
||||
setproctitle(f"treepuncher[{args.name}]")
|
||||
|
||||
kwargs = {}
|
||||
|
||||
if args.server:
|
||||
kwargs["server"] = args.server
|
||||
|
||||
if not os.path.isdir('log'):
|
||||
os.mkdir('log')
|
||||
if not os.path.isdir('data'):
|
||||
os.mkdir('data')
|
||||
|
||||
try:
|
||||
client = Treepuncher(
|
||||
args.name,
|
||||
server=args.server or None,
|
||||
online_mode=not args.offline,
|
||||
legacy=args.mojang,
|
||||
use_packet_whitelist=args.use_packet_whitelist,
|
||||
code=args.code,
|
||||
)
|
||||
except MissingParameterError as e:
|
||||
return logging.error(e.args[0])
|
||||
|
||||
enabled_addons = set(
|
||||
a.lower() for a in (
|
||||
args.add if args.add is not None else client.config.sections()
|
||||
)
|
||||
)
|
||||
|
||||
# TODO ugly af! providers get installed first tho
|
||||
|
||||
for addon in addons:
|
||||
if addon.__name__.lower() in enabled_addons and issubclass(addon, Provider):
|
||||
logging.info("Installing '%s'", addon.__name__)
|
||||
client.install(addon)
|
||||
|
||||
for addon in addons:
|
||||
if addon.__name__.lower() in enabled_addons and not issubclass(addon, Provider):
|
||||
logging.info("Installing '%s'", addon.__name__)
|
||||
client.install(addon)
|
||||
|
||||
client.run()
|
||||
|
||||
if args.print_token:
|
||||
logging.info("Token: %s", client.authenticator.serialize())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from typing import TYPE_CHECKING, Dict, Any, Optional, Union, List, Callable, get_type_hints, get_args, get_origin
|
||||
from dataclasses import dataclass, MISSING, fields
|
||||
|
||||
from treepuncher.storage import AddonStorage
|
||||
|
||||
from .scaffold import ConfigObject
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .treepuncher import Treepuncher
|
||||
|
||||
def parse_with_hint(val:str, hint:Any) -> Any:
|
||||
if hint is bool:
|
||||
if val.lower() in ['1', 'true', 't', 'on', 'enabled']:
|
||||
return True
|
||||
return False
|
||||
if hint is list or get_origin(hint) is list:
|
||||
if get_args(hint):
|
||||
return list( parse_with_hint(x, get_args(hint)[0]) for x in val.split() )
|
||||
return val.split()
|
||||
if hint is tuple or get_origin(hint) is tuple:
|
||||
if get_args(hint):
|
||||
return tuple( parse_with_hint(x, get_args(hint)[0]) for x in val.split() )
|
||||
return val.split()
|
||||
if hint is set or get_origin(hint) is set:
|
||||
if get_args(hint):
|
||||
return set( parse_with_hint(x, get_args(hint)[0]) for x in val.split() )
|
||||
return set(val.split())
|
||||
if hint is dict or get_origin(hint) is dict:
|
||||
return json.loads(val)
|
||||
if hint is Union or get_origin(hint) is Union:
|
||||
for t in get_args(hint):
|
||||
if t is type(None) and val in ("null", ""):
|
||||
return None
|
||||
if t is str:
|
||||
continue # try this last, will always succeed
|
||||
try:
|
||||
return t(val)
|
||||
except ValueError:
|
||||
pass
|
||||
if any(t is str for t in get_args(hint)):
|
||||
return str(val)
|
||||
return (get_origin(hint) or hint)(val) # try to instantiate directly
|
||||
|
||||
class Addon:
|
||||
name: str
|
||||
config: ConfigObject
|
||||
storage: AddonStorage
|
||||
logger: logging.Logger
|
||||
|
||||
_client: 'Treepuncher'
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Options(ConfigObject):
|
||||
pass
|
||||
|
||||
@property
|
||||
def client(self) -> 'Treepuncher':
|
||||
return self._client
|
||||
|
||||
def __init__(self, client: 'Treepuncher', *args, **kwargs):
|
||||
self._client = client
|
||||
self.name = type(self).__name__
|
||||
cfg = self._client.config
|
||||
opts: Dict[str, Any] = {}
|
||||
# get_type_hints attempts to instantiate all string hints (such as 'Treepuncher').
|
||||
# But we can't import Treepuncher here: would be a cyclic import!
|
||||
# We don't care about Treepuncher annotation, so we force it to be None
|
||||
cfg_clazz = get_type_hints(type(self), localns={'Treepuncher': None})['config'] # TODO jank localns override
|
||||
if cfg_clazz is not ConfigObject:
|
||||
for field in fields(cfg_clazz):
|
||||
default = field.default if field.default is not MISSING \
|
||||
else field.default_factory() if field.default_factory is not MISSING \
|
||||
else MISSING
|
||||
if cfg.has_option(self.name, field.name):
|
||||
opts[field.name] = parse_with_hint(self._client.config[self.name].get(field.name), field.type)
|
||||
elif default is MISSING:
|
||||
repr_type = field.type.__name__ if isinstance(field.type, type) else str(field.type) # TODO fix for 3.8 I think?
|
||||
raise ValueError(
|
||||
f"Missing required value '{field.name}' of type '{repr_type}' in section '{self.name}'"
|
||||
)
|
||||
else: # not really necessary since it's a dataclass but whatever
|
||||
opts[field.name] = default
|
||||
self.config = self.Options(**opts)
|
||||
self.storage = self.init_storage()
|
||||
self.logger = self._client.logger.getChild(self.name)
|
||||
self.register()
|
||||
|
||||
def register(self):
|
||||
pass
|
||||
|
||||
def init_storage(self) -> AddonStorage:
|
||||
return self.client.storage.addon_storage(self.name)
|
||||
|
||||
async def initialize(self):
|
||||
pass
|
||||
|
||||
async def cleanup(self):
|
||||
pass
|
|
@ -1,6 +0,0 @@
|
|||
from .chat import ChatEvent
|
||||
from .join_game import JoinGameEvent
|
||||
from .death import DeathEvent
|
||||
from .system import ConnectedEvent, DisconnectedEvent
|
||||
from .connection import PlayerJoinEvent, PlayerLeaveEvent
|
||||
from .block_update import BlockUpdateEvent
|
|
@ -1,2 +0,0 @@
|
|||
class BaseEvent:
|
||||
pass
|
|
@ -1,13 +0,0 @@
|
|||
from aiocraft.types import BlockPos
|
||||
|
||||
from .base import BaseEvent
|
||||
|
||||
class BlockUpdateEvent(BaseEvent):
|
||||
SENTINEL = object()
|
||||
|
||||
location : BlockPos
|
||||
state : int
|
||||
|
||||
def __init__(self, location: BlockPos, state: int):
|
||||
self.location = location
|
||||
self.state = state
|
|
@ -1,14 +0,0 @@
|
|||
from aiocraft.types import Player
|
||||
from .base import BaseEvent
|
||||
|
||||
class PlayerJoinEvent(BaseEvent):
|
||||
player: Player
|
||||
|
||||
def __init__(self, p:Player):
|
||||
self.player = p
|
||||
|
||||
class PlayerLeaveEvent(BaseEvent):
|
||||
player: Player
|
||||
|
||||
def __init__(self, p:Player):
|
||||
self.player = p
|
|
@ -1,4 +0,0 @@
|
|||
from .base import BaseEvent
|
||||
|
||||
class DeathEvent(BaseEvent):
|
||||
SENTINEL = object()
|
|
@ -1,15 +0,0 @@
|
|||
from aiocraft.types import Dimension, Difficulty, Gamemode
|
||||
|
||||
from .base import BaseEvent
|
||||
|
||||
class JoinGameEvent(BaseEvent):
|
||||
SENTINEL = object()
|
||||
|
||||
dimension : Dimension
|
||||
difficulty : Difficulty
|
||||
gamemode : Gamemode
|
||||
|
||||
def __init__(self, dimension:Dimension, difficulty:Difficulty, gamemode:Gamemode):
|
||||
self.gamemode = gamemode
|
||||
self.difficulty = difficulty
|
||||
self.dimension = dimension
|
|
@ -1,8 +0,0 @@
|
|||
from aiocraft.packet import Packet
|
||||
|
||||
from .base import BaseEvent
|
||||
|
||||
class PacketEvent(BaseEvent):
|
||||
packet : Packet
|
||||
def __init__(self, p:Packet):
|
||||
self.packet = p
|
|
@ -1,8 +0,0 @@
|
|||
from .base import BaseEvent
|
||||
|
||||
|
||||
class ConnectedEvent(BaseEvent):
|
||||
pass
|
||||
|
||||
class DisconnectedEvent(BaseEvent):
|
||||
pass
|
|
@ -1,6 +0,0 @@
|
|||
from .state import GameState
|
||||
from .inventory import GameInventory
|
||||
from .tablist import GameTablist
|
||||
from .chat import GameChat
|
||||
from .world import GameWorld
|
||||
from .container import GameContainer
|
|
@ -1,23 +0,0 @@
|
|||
from aiocraft.proto.play.clientbound import PacketChat as PacketChatMessage
|
||||
from aiocraft.proto.play.serverbound import PacketChat
|
||||
|
||||
from ..events.chat import ChatEvent
|
||||
from ..scaffold import Scaffold
|
||||
|
||||
class GameChat(Scaffold):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@self.on_packet(PacketChatMessage)
|
||||
async def chat_event_callback(packet:PacketChatMessage):
|
||||
self.run_callbacks(ChatEvent, ChatEvent(packet.message))
|
||||
|
||||
async def chat(self, message:str, whisper:str="", wait:bool=False):
|
||||
if whisper:
|
||||
message = f"/w {whisper} {message}"
|
||||
await self.dispatcher.write(
|
||||
PacketChat(message=message),
|
||||
wait=wait
|
||||
)
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
from aiocraft.types import Item
|
||||
from aiocraft.proto.play.clientbound import PacketTransaction
|
||||
from aiocraft.proto.play.serverbound import PacketTransaction as PacketTransactionServerbound
|
||||
from aiocraft.proto import (
|
||||
PacketOpenWindow, PacketCloseWindow, PacketSetSlot
|
||||
)
|
||||
|
||||
from ..events import DisconnectedEvent
|
||||
from ..scaffold import Scaffold
|
||||
|
||||
class WindowContainer:
|
||||
id: int
|
||||
title: str
|
||||
type: str
|
||||
entity_id: int | None
|
||||
transaction_id: int
|
||||
inventory: list[Item | None]
|
||||
|
||||
def __init__(self, id:int, title: str, type: str, entity_id:int | None = None, slot_count:int = 27):
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.type = type
|
||||
self.entity_id = entity_id
|
||||
self.transaction_id = 0
|
||||
self.inventory = [ None ] * (slot_count + 36)
|
||||
|
||||
@property
|
||||
def next_tid(self) -> int:
|
||||
self.transaction_id += 1
|
||||
if self.transaction_id > 32767:
|
||||
self.transaction_id = -32768 # force short overflow since this is sent over the socket as a short
|
||||
return self.transaction_id
|
||||
|
||||
class GameContainer(Scaffold):
|
||||
window: WindowContainer | None
|
||||
|
||||
@property
|
||||
def is_container_open(self) -> bool:
|
||||
return self.window is not None
|
||||
|
||||
async def close_container(self):
|
||||
await self.dispatcher.write(
|
||||
PacketCloseWindow(
|
||||
self.dispatcher.proto,
|
||||
windowId=self.window.id
|
||||
)
|
||||
)
|
||||
self.window = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.window = None
|
||||
|
||||
@self.on(DisconnectedEvent)
|
||||
async def disconnected_cb(_):
|
||||
self.window = None
|
||||
|
||||
@self.on_packet(PacketOpenWindow)
|
||||
async def on_player_open_window(packet:PacketOpenWindow):
|
||||
assert isinstance(packet.inventoryType, str)
|
||||
window_entity_id = packet.entityId if packet.inventoryType == "EntityHorse" and hasattr(packet, "entityId") else None
|
||||
self.window = WindowContainer(
|
||||
packet.windowId,
|
||||
packet.windowTitle,
|
||||
packet.inventoryType,
|
||||
entity_id=window_entity_id,
|
||||
slot_count=packet.slotCount or 27
|
||||
)
|
||||
|
||||
@self.on_packet(PacketSetSlot)
|
||||
async def on_set_slot(packet:PacketSetSlot):
|
||||
if packet.windowId == 0:
|
||||
self.window = None
|
||||
elif self.window and packet.windowId == self.window.id:
|
||||
self.window.inventory[packet.slot] = packet.item
|
||||
|
||||
@self.on_packet(PacketTransaction)
|
||||
async def on_transaction_denied(packet:PacketTransaction):
|
||||
if self.window and packet.windowId == self.window.id:
|
||||
if not packet.accepted: # apologize to server automatically
|
||||
await self.dispatcher.write(
|
||||
PacketTransactionServerbound(
|
||||
windowId=packet.windowId,
|
||||
action=packet.action,
|
||||
accepted=packet.accepted,
|
||||
)
|
||||
)
|
|
@ -1,39 +0,0 @@
|
|||
from typing import List
|
||||
|
||||
from aiocraft.types import Item
|
||||
from aiocraft.proto.play.clientbound import PacketSetSlot, PacketHeldItemSlot as PacketHeldItemChange
|
||||
from aiocraft.proto.play.serverbound import PacketHeldItemSlot
|
||||
|
||||
from ..scaffold import Scaffold
|
||||
|
||||
class GameInventory(Scaffold):
|
||||
slot : int
|
||||
inventory : List[Item]
|
||||
# TODO inventory
|
||||
|
||||
async def set_slot(self, slot:int):
|
||||
self.slot = slot
|
||||
await self.dispatcher.write(PacketHeldItemSlot(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
|
|
@ -1,150 +0,0 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
|
||||
#from aiocraft.client import MinecraftClient
|
||||
from aiocraft.types import Gamemode, Dimension, Difficulty
|
||||
from aiocraft.proto import (
|
||||
PacketRespawn, PacketLogin, PacketUpdateHealth, PacketExperience, PacketSettings,
|
||||
PacketClientCommand, PacketAbilities, PacketDifficulty
|
||||
)
|
||||
|
||||
from ..events import JoinGameEvent, DeathEvent, DisconnectedEvent
|
||||
from ..scaffold import Scaffold
|
||||
|
||||
class GameState(Scaffold):
|
||||
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
|
||||
|
||||
# Abilities
|
||||
flags : int
|
||||
flyingSpeed : float
|
||||
walkingSpeed : float
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.in_game = False
|
||||
self.gamemode = Gamemode.UNKNOWN
|
||||
self.dimension = Dimension.UNKNOWN
|
||||
self.difficulty = Difficulty.UNKNOWN
|
||||
self.join_time = datetime.datetime(2011, 11, 18)
|
||||
|
||||
self.hp = 20.0
|
||||
self.food = 20.0
|
||||
self.xp = 0.0
|
||||
self.lvl = 0
|
||||
self.total_xp = 0
|
||||
|
||||
@self.on(DisconnectedEvent)
|
||||
async def disconnected_cb(_):
|
||||
self.in_game = False
|
||||
|
||||
@self.on_packet(PacketRespawn)
|
||||
async def on_player_respawning(packet:PacketRespawn):
|
||||
self.gamemode = Gamemode(packet.gamemode)
|
||||
if isinstance(packet.dimension, dict):
|
||||
self.logger.info("Received dimension data: %s", json.dumps(packet.dimension, indent=2))
|
||||
self.dimension = Dimension.from_str(packet.dimension['effects'])
|
||||
else:
|
||||
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(PacketDifficulty)
|
||||
async def on_set_difficulty(packet:PacketDifficulty):
|
||||
self.difficulty = Difficulty(packet.difficulty)
|
||||
self.logger.info("Difficulty set to %s", self.difficulty.name)
|
||||
|
||||
@self.on_packet(PacketLogin)
|
||||
async def player_joining_cb(packet:PacketLogin):
|
||||
self.entity_id = packet.entityId
|
||||
self.gamemode = Gamemode(packet.gameMode)
|
||||
if isinstance(packet.dimension, dict):
|
||||
with open('world_codec.json', 'w') as f:
|
||||
json.dump(packet.dimensionCodec, f)
|
||||
self.dimension = Dimension.from_str(packet.dimension['effects'])
|
||||
else:
|
||||
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, JoinGameEvent(self.dimension, self.difficulty, self.gamemode))
|
||||
await self.dispatcher.write(
|
||||
PacketSettings(
|
||||
locale="en_US",
|
||||
viewDistance=4,
|
||||
chatFlags=0,
|
||||
chatColors=True,
|
||||
skinParts=0xF,
|
||||
mainHand=0,
|
||||
)
|
||||
)
|
||||
await self.dispatcher.write(PacketClientCommand(actionId=0))
|
||||
|
||||
@self.on_packet(PacketUpdateHealth)
|
||||
async def player_hp_cb(packet:PacketUpdateHealth):
|
||||
died = packet.health != self.hp and packet.health <= 0
|
||||
if self.hp != packet.health:
|
||||
if self.hp < packet.health:
|
||||
self.logger.info("Healed by %.1f (%.1f HP)", packet.health - self.hp, packet.health)
|
||||
else:
|
||||
self.logger.info("Took %.1f damage (%.1f HP)", self.hp - packet.health, packet.health)
|
||||
self.hp = packet.health
|
||||
self.food = packet.food + packet.foodSaturation
|
||||
if died:
|
||||
self.run_callbacks(DeathEvent, DeathEvent())
|
||||
self.logger.warning("Died, attempting to respawn")
|
||||
await asyncio.sleep(0.5) # TODO make configurable
|
||||
await self.dispatcher.write(
|
||||
PacketClientCommand(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(PacketAbilities)
|
||||
async def player_abilities_cb(packet:PacketAbilities):
|
||||
self.flags = packet.flags
|
||||
self.flyingSpeed = packet.flyingSpeed
|
||||
self.walkingSpeed = packet.walkingSpeed
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import uuid
|
||||
import datetime
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from aiocraft.types import Player
|
||||
from aiocraft.proto import PacketPlayerInfo
|
||||
|
||||
from ..scaffold import Scaffold
|
||||
from ..events import ConnectedEvent, PlayerJoinEvent, PlayerLeaveEvent
|
||||
|
||||
class ActionType(Enum): # TODO move this in aiocraft
|
||||
ADD_PLAYER = 0
|
||||
UPDATE_GAMEMODE = 1
|
||||
UPDATE_LATENCY = 2
|
||||
UPDATE_DISPLAY_NAME = 3
|
||||
REMOVE_PLAYER = 4
|
||||
|
||||
class GameTablist(Scaffold):
|
||||
tablist : dict[uuid.UUID, Player]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.tablist = {}
|
||||
|
||||
@self.on(ConnectedEvent)
|
||||
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 != ActionType.ADD_PLAYER.value and uid not in self.tablist:
|
||||
continue # TODO this happens kinda often but doesn't seem to be an issue?
|
||||
if packet.action == ActionType.ADD_PLAYER.value:
|
||||
record['joinTime'] = datetime.datetime.now()
|
||||
self.tablist[uid] = Player.deserialize(record) # TODO have it be a Player type inside packet
|
||||
self.run_callbacks(PlayerJoinEvent, PlayerJoinEvent(Player.deserialize(record)))
|
||||
elif packet.action == ActionType.UPDATE_GAMEMODE.value:
|
||||
self.tablist[uid].gamemode = record['gamemode']
|
||||
elif packet.action == ActionType.UPDATE_LATENCY.value:
|
||||
self.tablist[uid].ping = record['ping']
|
||||
elif packet.action == ActionType.UPDATE_DISPLAY_NAME.value:
|
||||
self.tablist[uid].displayName = record['displayName']
|
||||
elif packet.action == ActionType.REMOVE_PLAYER.value:
|
||||
self.tablist.pop(uid, None)
|
||||
self.run_callbacks(PlayerLeaveEvent, PlayerLeaveEvent(Player.deserialize(record)))
|
||||
|
||||
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
import json
|
||||
from time import time
|
||||
|
||||
from aiocraft.types import BlockPos
|
||||
from aiocraft.proto import (
|
||||
PacketMapChunk, PacketBlockChange, PacketMultiBlockChange, PacketSetPassengers, PacketEntityTeleport,
|
||||
PacketSteerVehicle, PacketRelEntityMove, PacketTeleportConfirm
|
||||
)
|
||||
from aiocraft.proto.play.clientbound import PacketPosition
|
||||
from aiocraft.primitives import twos_comp
|
||||
|
||||
from aiocraft import Chunk, World # TODO these imports will hopefully change!
|
||||
|
||||
from ..scaffold import Scaffold
|
||||
from ..events import BlockUpdateEvent
|
||||
|
||||
class GameWorld(Scaffold):
|
||||
position : BlockPos
|
||||
vehicle_id : int | None
|
||||
world : World
|
||||
|
||||
_last_steer_vehicle : float
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.world = World()
|
||||
self.position = BlockPos(0, 0, 0)
|
||||
self.vehicle_id = None
|
||||
self._last_steer_vehicle = time()
|
||||
|
||||
@self.on_packet(PacketSetPassengers)
|
||||
async def player_enters_vehicle_cb(packet:PacketSetPassengers):
|
||||
if self.vehicle_id is None: # might get mounted on a vehicle
|
||||
for entity_id in packet.passengers:
|
||||
if entity_id == self.entity_id:
|
||||
self.vehicle_id = packet.entityId
|
||||
else: # might get dismounted from vehicle
|
||||
if packet.entityId == self.vehicle_id:
|
||||
if self.entity_id not in packet.passengers:
|
||||
self.vehicle_id = None
|
||||
|
||||
@self.on_packet(PacketEntityTeleport)
|
||||
async def entity_rubberband_cb(packet:PacketEntityTeleport):
|
||||
if self.vehicle_id is None:
|
||||
return
|
||||
if self.vehicle_id != packet.entityId:
|
||||
return
|
||||
self.position = BlockPos(packet.x, packet.y, packet.z)
|
||||
self.logger.info(
|
||||
"Position synchronized : (x:%.0f,y:%.0f,z:%.0f) (vehicle)",
|
||||
self.position.x, self.position.y, self.position.z
|
||||
)
|
||||
|
||||
@self.on_packet(PacketRelEntityMove)
|
||||
async def entity_relative_move_cb(packet:PacketRelEntityMove):
|
||||
if self.vehicle_id is None:
|
||||
return
|
||||
if self.vehicle_id != packet.entityId:
|
||||
return
|
||||
self.position = BlockPos(
|
||||
self.position.x + packet.dX,
|
||||
self.position.y + packet.dY,
|
||||
self.position.z + packet.dZ
|
||||
)
|
||||
self.logger.debug(
|
||||
"Position synchronized : (x:%.0f,y:%.0f,z:%.0f) (relMove vehicle)",
|
||||
self.position.x, self.position.y, self.position.z
|
||||
)
|
||||
if time() - self._last_steer_vehicle >= 5:
|
||||
self._last_steer_vehicle = time()
|
||||
await self.dispatcher.write(
|
||||
PacketSteerVehicle(forward=0, sideways=0, jump=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(teleportId=packet.teleportId)
|
||||
)
|
||||
|
||||
# Since this might require more resources, allow to disable it
|
||||
if not self.cfg.getboolean("process_world", fallback=False):
|
||||
return
|
||||
|
||||
@self.on_packet(PacketMapChunk)
|
||||
async def map_chunk_cb(packet:PacketMapChunk):
|
||||
assert isinstance(packet.bitMap, int)
|
||||
c = Chunk(packet.x, packet.z, packet.bitMap, packet.groundUp, json.dumps(packet.blockEntities)) # TODO a solution which is not jank!
|
||||
c.read(packet.chunkData)
|
||||
self.world.put(c, packet.x, packet.z, not packet.groundUp)
|
||||
|
||||
@self.on_packet(PacketBlockChange)
|
||||
async def block_change_cb(packet:PacketBlockChange):
|
||||
self.world.put_block(packet.location[0], packet.location[1], packet.location[2], packet.type)
|
||||
pos = BlockPos(packet.location[0], packet.location[1], packet.location[2])
|
||||
self.run_callbacks(BlockUpdateEvent, BlockUpdateEvent(pos, packet.type))
|
||||
|
||||
@self.on_packet(PacketMultiBlockChange)
|
||||
async def multi_block_change_cb(packet:PacketMultiBlockChange):
|
||||
if self.dispatcher.proto < 751:
|
||||
chunk_x_off = packet.chunkX * 16
|
||||
chunk_z_off = packet.chunkZ * 16
|
||||
for entry in packet.records:
|
||||
x_off = (entry['horizontalPos'] >> 4 ) & 15
|
||||
z_off = entry['horizontalPos'] & 15
|
||||
pos = BlockPos(x_off + chunk_x_off, entry['y'], z_off + chunk_z_off)
|
||||
self.world.put_block(pos.i_x, pos.i_y, pos.i_z, entry['blockId'])
|
||||
self.run_callbacks(BlockUpdateEvent, BlockUpdateEvent(pos, entry['blockId']))
|
||||
elif self.dispatcher.proto < 760:
|
||||
x = twos_comp((packet.chunkCoordinates >> 42) & 0x3FFFFF, 22)
|
||||
z = twos_comp((packet.chunkCoordinates >> 20) & 0x3FFFFF, 22)
|
||||
y = twos_comp((packet.chunkCoordinates ) & 0xFFFFF , 20)
|
||||
for loc in packet.records:
|
||||
state = loc >> 12
|
||||
dx = ((loc & 0x0FFF) >> 8 ) & 0x0F
|
||||
dz = ((loc & 0x0FFF) >> 4 ) & 0x0F
|
||||
dy = ((loc & 0x0FFF) ) & 0x0F
|
||||
pos = BlockPos(16*x + dx, 16*y + dy, 16*z + dz)
|
||||
self.world.put_block(pos.i_x, pos.i_y, pos.i_z, state)
|
||||
self.run_callbacks(BlockUpdateEvent, BlockUpdateEvent(pos, state))
|
||||
else:
|
||||
self.logger.error("Cannot process MultiBlockChange for protocol %d", self.dispatcher.proto)
|
|
@ -1,44 +0,0 @@
|
|||
import logging
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from termcolor import colored
|
||||
|
||||
def configure_logging(name:str, level=logging.INFO, color:bool = True, path:str = "log"):
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
class ColorFormatter(logging.Formatter):
|
||||
def __init__(self, fmt:str, datefmt:str=None):
|
||||
self.fmt : str = fmt
|
||||
self.formatters : Dict[int, logging.Formatter] = {
|
||||
logging.DEBUG: logging.Formatter(colored(fmt, color='grey'), datefmt),
|
||||
logging.INFO: logging.Formatter(colored(fmt), datefmt),
|
||||
logging.WARNING: logging.Formatter(colored(fmt, color='yellow'), datefmt),
|
||||
logging.ERROR: logging.Formatter(colored(fmt, color='red'), datefmt),
|
||||
logging.CRITICAL: logging.Formatter(colored(fmt, color='red', attrs=['bold']), datefmt),
|
||||
}
|
||||
|
||||
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
|
||||
if not os.path.isdir(path):
|
||||
os.mkdir(path)
|
||||
fh = RotatingFileHandler(f'{path}/{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.%(msecs)03d] [%(name)s] [%(levelname)s] %(message)s", "%b %d %Y %H:%M:%S")
|
||||
print_formatter = ColorFormatter("%(asctime)s| %(message)s", "%H:%M:%S") if color else 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,59 +0,0 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from typing import List, Callable, Optional, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .treepuncher import Treepuncher
|
||||
|
||||
from .addon import Addon
|
||||
|
||||
class Provider(Addon):
|
||||
async def notify(self, text, log:bool = False, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
class Notifier:
|
||||
_report_functions : List[Callable]
|
||||
_providers : List[Provider]
|
||||
_client : 'Treepuncher'
|
||||
logger : logging.Logger
|
||||
|
||||
def __init__(self, client:'Treepuncher'):
|
||||
self._report_functions = []
|
||||
self._providers = []
|
||||
self._client = client
|
||||
self.logger = client.logger.getChild("notifier")
|
||||
|
||||
@property
|
||||
def providers(self) -> List[Provider]:
|
||||
return self._providers
|
||||
|
||||
def add_reporter(self, fn:Callable):
|
||||
self._report_functions.append(fn)
|
||||
return fn
|
||||
|
||||
def add_provider(self, p:Provider):
|
||||
self._providers.append(p)
|
||||
|
||||
def get_provider(self, name:str) -> Optional[Provider]:
|
||||
for p in self.providers:
|
||||
if p.name == name:
|
||||
return p
|
||||
return None
|
||||
|
||||
def report(self) -> str:
|
||||
return '\n'.join(str(fn()).strip() for fn in self._report_functions)
|
||||
|
||||
async def notify(self, text, log:bool = False, **kwargs):
|
||||
self.logger.info("%s %s (%s)", "[n]" if log else "[N]", text, str(kwargs))
|
||||
await asyncio.gather(
|
||||
*(p.notify(text, log=log, **kwargs) for p in self.providers)
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
await asyncio.gather(
|
||||
*(p.initialize() for p in self.providers)
|
||||
)
|
||||
|
||||
async def stop(self):
|
||||
await asyncio.gather(
|
||||
*(p.cleanup() for p in self.providers)
|
||||
)
|
|
@ -1,65 +0,0 @@
|
|||
from configparser import ConfigParser, SectionProxy
|
||||
|
||||
from typing import Type, Any
|
||||
|
||||
from aiocraft.client import AbstractMinecraftClient
|
||||
from aiocraft.util import helpers
|
||||
from aiocraft.packet import Packet
|
||||
from aiocraft.types import ConnectionState
|
||||
from aiocraft.proto import PacketKickDisconnect, PacketSetCompression
|
||||
from aiocraft.proto.play.clientbound import PacketKeepAlive
|
||||
from aiocraft.proto.play.serverbound import PacketKeepAlive as PacketKeepAliveResponse
|
||||
|
||||
from .traits import CallbacksHolder, Runnable
|
||||
from .events import ConnectedEvent, DisconnectedEvent
|
||||
from .events.base import BaseEvent
|
||||
|
||||
class ConfigObject:
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return getattr(self, key)
|
||||
|
||||
class Scaffold(
|
||||
CallbacksHolder,
|
||||
Runnable,
|
||||
AbstractMinecraftClient,
|
||||
):
|
||||
entity_id : int
|
||||
|
||||
config: ConfigParser
|
||||
|
||||
@property
|
||||
def cfg(self) -> SectionProxy:
|
||||
return SectionProxy(self.config, "Treepuncher")
|
||||
|
||||
def on_packet(self, packet:Type[Packet]):
|
||||
def decorator(fun):
|
||||
return self.register(packet, fun)
|
||||
return decorator
|
||||
|
||||
def on(self, event:Type[BaseEvent]):
|
||||
def decorator(fun):
|
||||
return self.register(event, fun)
|
||||
return decorator
|
||||
|
||||
#Override
|
||||
async def _play(self) -> bool:
|
||||
assert self.dispatcher is not None
|
||||
self.dispatcher.promote(ConnectionState.PLAY)
|
||||
self.run_callbacks(ConnectedEvent, ConnectedEvent())
|
||||
async for packet in self.dispatcher.packets():
|
||||
self.logger.debug("[ * ] Processing %s", packet.__class__.__name__)
|
||||
if isinstance(packet, PacketSetCompression):
|
||||
self.logger.info("Compression updated")
|
||||
self.dispatcher.update_compression_threshold(packet.threshold)
|
||||
elif isinstance(packet, PacketKeepAlive):
|
||||
if self.cfg.getboolean("send_keep_alive", fallback=True):
|
||||
keep_alive_packet = PacketKeepAliveResponse(keepAliveId=packet.keepAliveId)
|
||||
await self.dispatcher.write(keep_alive_packet)
|
||||
elif isinstance(packet, PacketKickDisconnect):
|
||||
self.logger.error("Kicked while in game : %s", helpers.parse_chat(packet.reason))
|
||||
break
|
||||
self.run_callbacks(type(packet), packet)
|
||||
self.run_callbacks(Packet, packet)
|
||||
self.run_callbacks(DisconnectedEvent, DisconnectedEvent())
|
||||
return False
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any, Dict
|
||||
from datetime import datetime
|
||||
|
||||
__DATE_FORMAT__ : str = "%Y-%m-%d %H:%M:%S.%f"
|
||||
|
||||
@dataclass
|
||||
class SystemState:
|
||||
name : str
|
||||
version : str
|
||||
start_time : int
|
||||
|
||||
@dataclass
|
||||
class AuthenticatorState:
|
||||
date : datetime
|
||||
token : Dict[str, Any]
|
||||
legacy : bool = False
|
||||
|
||||
class AddonStorage:
|
||||
# TODO this uses py formatting in SQL queries, can we avoid it?
|
||||
db: sqlite3.Connection
|
||||
name: str
|
||||
|
||||
def __init__(self, db:sqlite3.Connection, name:str):
|
||||
self.db = db
|
||||
self.name = name
|
||||
self.db.cursor().execute(f'CREATE TABLE IF NOT EXISTS documents_{self.name} (name TEXT PRIMARY KEY, value TEXT)')
|
||||
self.db.commit()
|
||||
|
||||
# fstrings in queries are evil but if you go to this length to fuck up you kinda deserve it :)
|
||||
def get(self, key:str) -> Optional[Any]:
|
||||
res = self.db.cursor().execute(f"SELECT * FROM documents_{self.name} WHERE name = ?", (key,)).fetchall()
|
||||
return json.loads(res[0][1]) if res else None
|
||||
|
||||
def put(self, key:str, val:Any) -> None:
|
||||
cur = self.db.cursor()
|
||||
cur.execute(f"DELETE FROM documents_{self.name} WHERE name = ?", (key,))
|
||||
cur.execute(f"INSERT INTO documents_{self.name} VALUES (?, ?)", (key, json.dumps(val, default=str),))
|
||||
self.db.commit()
|
||||
|
||||
class StorageDriver:
|
||||
name : str
|
||||
db : sqlite3.Connection
|
||||
|
||||
def __init__(self, name:str):
|
||||
self.name = name
|
||||
init = not os.path.isfile(name)
|
||||
self.db = sqlite3.connect(name)
|
||||
if init:
|
||||
self._init_db()
|
||||
|
||||
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 PRIMARY KEY, version TEXT, start_time LONG)')
|
||||
cur.execute('CREATE TABLE documents (name TEXT PRIMARY KEY, value TEXT)')
|
||||
cur.execute('CREATE TABLE authenticator (date TEXT PRIMARY KEY, token TEXT, legacy BOOL)')
|
||||
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.version, int(state.start_time)))
|
||||
self.db.commit()
|
||||
|
||||
def _set_auth(self, state:AuthenticatorState):
|
||||
cur = self.db.cursor()
|
||||
cur.execute('DELETE FROM authenticator')
|
||||
cur.execute('INSERT INTO authenticator VALUES (?, ?, ?)', (state.date.strftime(__DATE_FORMAT__), json.dumps(state.token), state.legacy))
|
||||
self.db.commit()
|
||||
|
||||
def addon_storage(self, name:str) -> AddonStorage:
|
||||
return AddonStorage(self.db, name)
|
||||
|
||||
def system(self) -> Optional[SystemState]:
|
||||
cur = self.db.cursor()
|
||||
val = cur.execute('SELECT * FROM system').fetchall()
|
||||
if not val:
|
||||
return None
|
||||
return SystemState(
|
||||
name=val[0][0],
|
||||
version=val[0][1],
|
||||
start_time=val[0][2]
|
||||
)
|
||||
|
||||
def auth(self) -> Optional[AuthenticatorState]:
|
||||
cur = self.db.cursor()
|
||||
val = cur.execute('SELECT * FROM authenticator').fetchall()
|
||||
if not val:
|
||||
return None
|
||||
return AuthenticatorState(
|
||||
date=datetime.strptime(val[0][0], __DATE_FORMAT__),
|
||||
token=json.loads(val[0][1]),
|
||||
legacy=val[0][2] or False
|
||||
)
|
||||
|
||||
def get(self, key:str) -> Optional[Any]:
|
||||
val = self.db.cursor().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, default=str)))
|
||||
self.db.commit()
|
||||
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from .callbacks import CallbacksHolder
|
||||
from .runnable import Runnable
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import asyncio
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from inspect import isclass
|
||||
from typing import Dict, List, Set, Any, Callable, Type
|
||||
|
||||
class CallbacksHolder:
|
||||
|
||||
_callbacks : Dict[Any, List[Callable]]
|
||||
_tasks : Dict[uuid.UUID, asyncio.Task]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._callbacks = {}
|
||||
self._tasks = {}
|
||||
|
||||
def callback_keys(self, filter:Type | None = None) -> Set[Any]:
|
||||
return set(x for x in self._callbacks.keys() if not filter or (isclass(x) and issubclass(x, filter)))
|
||||
|
||||
def register(self, key:Any, callback:Callable):
|
||||
if key not in self._callbacks:
|
||||
self._callbacks[key] = []
|
||||
self._callbacks[key].append(callback)
|
||||
return callback
|
||||
|
||||
def trigger(self, key:Any) -> List[Callable]:
|
||||
if key not in self._callbacks:
|
||||
return []
|
||||
return self._callbacks[key]
|
||||
|
||||
def _wrap(self, cb:Callable, uid:uuid.UUID) -> Callable:
|
||||
async def wrapper(*args):
|
||||
try:
|
||||
return await cb(*args)
|
||||
except Exception:
|
||||
logging.exception("Exception processing callback '%s'", cb.__name__)
|
||||
return None
|
||||
finally:
|
||||
self._tasks.pop(uid)
|
||||
return wrapper
|
||||
|
||||
def run_callbacks(self, key:Any, *args) -> None:
|
||||
for cb in self.trigger(key):
|
||||
task_id = uuid.uuid4()
|
||||
self._tasks[task_id] = asyncio.get_event_loop().create_task(self._wrap(cb, task_id)(*args))
|
||||
|
||||
async def join_callbacks(self):
|
||||
await asyncio.gather(*list(self._tasks.values()))
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
from typing import Optional
|
||||
from signal import signal, SIGINT, SIGTERM
|
||||
|
||||
class Runnable:
|
||||
_is_running : bool
|
||||
_stop_task : Optional[asyncio.Task]
|
||||
_loop : asyncio.AbstractEventLoop
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._is_running = False
|
||||
self._stop_task = None
|
||||
self._loop = asyncio.get_event_loop()
|
||||
|
||||
async def start(self):
|
||||
self._is_running = True
|
||||
|
||||
async def stop(self, force:bool=False):
|
||||
self._is_running = False
|
||||
|
||||
def run(self):
|
||||
logging.info("Starting process")
|
||||
|
||||
def signal_handler(signum, __):
|
||||
if signum == SIGINT:
|
||||
if self._stop_task:
|
||||
self._stop_task.cancel()
|
||||
logging.info("Received SIGINT, terminating")
|
||||
else:
|
||||
logging.info("Received SIGINT, stopping gracefully...")
|
||||
self._stop_task = asyncio.get_event_loop().create_task(self.stop(force=self._stop_task is not None))
|
||||
if signum == SIGTERM:
|
||||
logging.info("Received SIGTERM, terminating")
|
||||
self._stop_task = asyncio.get_event_loop().create_task(self.stop(force=True))
|
||||
|
||||
|
||||
signal(SIGINT, signal_handler)
|
||||
|
||||
async def main():
|
||||
await self.start()
|
||||
while self._is_running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
self._loop.run_until_complete(main())
|
||||
|
||||
logging.info("Process finished")
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import datetime
|
||||
import pkg_resources
|
||||
|
||||
from typing import Any, Type
|
||||
from time import time
|
||||
from configparser import ConfigParser
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from aiocraft.packet import Packet
|
||||
from aiocraft.auth import AuthInterface, AuthException, MojangAuthenticator, MicrosoftAuthenticator, OfflineAuthenticator
|
||||
from aiocraft.auth.microsoft import InvalidStateError
|
||||
|
||||
from .storage import StorageDriver, SystemState, AuthenticatorState
|
||||
from .game import GameState, GameChat, GameInventory, GameTablist, GameWorld, GameContainer
|
||||
from .addon import Addon
|
||||
from .notifier import Notifier, Provider
|
||||
|
||||
__VERSION__ = pkg_resources.get_distribution('treepuncher').version
|
||||
|
||||
async def _cleanup(m: Addon, l: logging.Logger):
|
||||
await m.cleanup()
|
||||
l.debug("Cleaned up addon %s", m.name)
|
||||
|
||||
class MissingParameterError(Exception):
|
||||
pass
|
||||
|
||||
class Treepuncher(
|
||||
GameState,
|
||||
GameChat,
|
||||
GameInventory,
|
||||
GameContainer,
|
||||
GameTablist,
|
||||
GameWorld,
|
||||
# GameMovement
|
||||
):
|
||||
name: str
|
||||
storage: StorageDriver
|
||||
|
||||
notifier: Notifier
|
||||
scheduler: AsyncIOScheduler
|
||||
modules: list[Addon]
|
||||
ctx: dict[Any, Any]
|
||||
|
||||
_processing: bool
|
||||
_proto_override: int
|
||||
_host: str
|
||||
_port: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
config_file: str = "",
|
||||
**kwargs
|
||||
):
|
||||
self.ctx = dict()
|
||||
|
||||
self.name = name
|
||||
self.config = ConfigParser()
|
||||
self.config.read(config_file or f"{self.name}.ini") # TODO wrap with pathlib
|
||||
|
||||
authenticator : AuthInterface
|
||||
|
||||
def opt(k, required=False, default=None, t=str):
|
||||
v = kwargs.get(k)
|
||||
if v is None:
|
||||
v = self.cfg.get(k)
|
||||
if v is None:
|
||||
v = default
|
||||
if not v and required:
|
||||
raise MissingParameterError(f"Missing configuration parameter '{k}'")
|
||||
if t is bool and isinstance(v, str) and v.lower().strip() == 'false': # hardcoded special case
|
||||
return False
|
||||
if v is None:
|
||||
return None
|
||||
return t(v)
|
||||
|
||||
if not opt('online_mode', default=True, t=bool):
|
||||
authenticator = OfflineAuthenticator(self.name)
|
||||
elif opt('legacy', default=False, t=bool):
|
||||
authenticator = MojangAuthenticator(
|
||||
username= opt('username', default=name, required=True),
|
||||
password= opt('password'),
|
||||
session_server_override= opt('session_server_override'),
|
||||
auth_server_override= opt('auth_server_override'),
|
||||
)
|
||||
if opt('legacy_token'):
|
||||
authenticator.deserialize(json.loads(opt('legacy_token')))
|
||||
else:
|
||||
authenticator = MicrosoftAuthenticator(
|
||||
client_id= opt('client_id', required=True),
|
||||
client_secret= opt('client_secret', required=True),
|
||||
redirect_uri= opt('redirect_uri', required=True),
|
||||
code= opt('code'),
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
authenticator=authenticator,
|
||||
online_mode=opt('online_mode', default=True, t=bool),
|
||||
)
|
||||
|
||||
self._proto_override = opt('force_proto', t=int)
|
||||
self._host = opt('server', required=True)
|
||||
if ":" in self._host:
|
||||
h, p = self._host.split(":", 1)
|
||||
self._host = h
|
||||
self._port = int(p)
|
||||
else:
|
||||
self._host, self._port = self.resolve_srv(self._host)
|
||||
|
||||
self.storage = StorageDriver(opt('session_file') or f"data/{name}.session") # TODO wrap with pathlib
|
||||
|
||||
self.notifier = Notifier(self)
|
||||
|
||||
self.modules = []
|
||||
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy
|
||||
self.scheduler.start(paused=True)
|
||||
|
||||
prev = self.storage.system() # if this isn't 1st time, this won't be None. Load token from there
|
||||
state = SystemState(self.name, __VERSION__, 0)
|
||||
if prev:
|
||||
state.start_time = prev.start_time
|
||||
if self.name != prev.name:
|
||||
self.logger.warning("Saved session belong to another user")
|
||||
if prev.version != state.version:
|
||||
self.logger.warning("Saved session uses a different version")
|
||||
prev_auth = self.storage.auth()
|
||||
if prev_auth:
|
||||
if prev_auth.legacy ^ isinstance(authenticator, MojangAuthenticator):
|
||||
self.logger.warning("Saved session is incompatible with configured authenticator")
|
||||
authenticator.deserialize(prev_auth.token)
|
||||
self.logger.info("Loaded session from %s", prev_auth.date)
|
||||
self.storage._set_state(state)
|
||||
|
||||
@property
|
||||
def playerName(self) -> str:
|
||||
return self.authenticator.selectedProfile.name
|
||||
|
||||
async def authenticate(self):
|
||||
sleep_interval = self.cfg.getfloat("auth_retry_interval", fallback=60.0)
|
||||
for _ in range(self.cfg.getint("auth_retry_count", fallback=5)):
|
||||
try:
|
||||
await super().authenticate()
|
||||
state = AuthenticatorState(
|
||||
date=datetime.datetime.now(),
|
||||
token=self.authenticator.serialize(),
|
||||
legacy=isinstance(self.authenticator, MojangAuthenticator)
|
||||
)
|
||||
self.storage._set_auth(state)
|
||||
return
|
||||
except AuthException as e:
|
||||
if e.data["error"] == "request timed out":
|
||||
await asyncio.sleep(sleep_interval)
|
||||
continue
|
||||
raise e # retrying won't help anyway
|
||||
|
||||
async def start(self):
|
||||
# if self.started: # TODO readd check
|
||||
# return
|
||||
await super().start()
|
||||
|
||||
await self.notifier.start()
|
||||
self.logger.debug("Notifier started")
|
||||
await asyncio.gather(
|
||||
*(m.initialize() for m in self.modules)
|
||||
)
|
||||
self.logger.debug("Addons initialized")
|
||||
self._processing = True
|
||||
self._worker = asyncio.get_event_loop().create_task(self._work())
|
||||
self.scheduler.resume()
|
||||
self.logger.info("Treepuncher started")
|
||||
self.storage._set_state(SystemState(self.name, __VERSION__, time()))
|
||||
|
||||
async def stop(self, force: bool = False):
|
||||
self._processing = False
|
||||
self.scheduler.pause()
|
||||
if self.dispatcher.connected:
|
||||
await self.dispatcher.disconnect(block=not force)
|
||||
if not force:
|
||||
await self._worker
|
||||
self.logger.debug("Joined worker")
|
||||
await self.join_callbacks()
|
||||
self.logger.debug("Joined callbacks")
|
||||
await asyncio.gather(
|
||||
*(_cleanup(m, self.logger) for m in self.modules)
|
||||
)
|
||||
self.logger.debug("Cleaned up addons")
|
||||
await self.notifier.stop()
|
||||
self.logger.debug("Notifier stopped")
|
||||
await super().stop()
|
||||
self.logger.info("Treepuncher stopped")
|
||||
|
||||
def install(self, module: Type[Addon]) -> Addon:
|
||||
m = module(self)
|
||||
if isinstance(m, Provider):
|
||||
self.notifier.add_provider(m)
|
||||
elif isinstance(m, Addon):
|
||||
self.modules.append(m)
|
||||
else:
|
||||
raise ValueError("Given type is not an addon")
|
||||
return m
|
||||
|
||||
async def _work(self):
|
||||
self.logger.debug("Worker started")
|
||||
try:
|
||||
log_ignored_packets = self.cfg.getboolean('log_ignored_packets', fallback=False)
|
||||
whitelist = self.callback_keys(filter=Packet)
|
||||
if self._proto_override:
|
||||
proto = self._proto_override
|
||||
else:
|
||||
try:
|
||||
server_data = await self.info(self._host, self._port, whitelist=whitelist, log_ignored_packets=log_ignored_packets)
|
||||
if "version" in server_data and "protocol" in server_data["version"]:
|
||||
proto = server_data['version']['protocol']
|
||||
except OSError as e:
|
||||
self.logger.error("Connection error : %s", str(e))
|
||||
|
||||
while self._processing:
|
||||
try:
|
||||
await self.join(self._host, self._port, proto, whitelist=whitelist, log_ignored_packets=log_ignored_packets)
|
||||
except OSError as e:
|
||||
self.logger.error("Connection error : %s", str(e))
|
||||
|
||||
if self._processing: # don't sleep if Treepuncher is stopping
|
||||
await asyncio.sleep(self.cfg.getfloat('reconnect_delay', fallback=5))
|
||||
|
||||
except AuthException as e:
|
||||
self.logger.error("Auth exception : [%s|%d] %s (%s)", e.endpoint, e.code, e.data, e.kwargs)
|
||||
except InvalidStateError:
|
||||
self.logger.error("Invalid authenticator state")
|
||||
if isinstance(self.authenticator, MicrosoftAuthenticator):
|
||||
self.logger.info("Obtain an auth code by visiting %s", self.authenticator.url())
|
||||
except Exception as e:
|
||||
self.logger.exception("Unhandled exception : %s", str(e))
|
||||
|
||||
if self._processing:
|
||||
await self.stop(force=True)
|
||||
self.logger.debug("Worker finished")
|
1
treepuncher/__init__.py
Normal file
1
treepuncher/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .treepuncher import Treepuncher
|
1
treepuncher/events/__init__.py
Normal file
1
treepuncher/events/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .chat import ChatEvent
|
|
@ -5,8 +5,6 @@ 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)
|
||||
|
@ -21,7 +19,7 @@ class MessageType(Enum):
|
|||
LEAVE = "leave"
|
||||
SYSTEM = "system"
|
||||
|
||||
class ChatEvent(BaseEvent):
|
||||
class ChatEvent:
|
||||
text : str
|
||||
type : MessageType
|
||||
user : str
|
1
treepuncher/modules/__init__.py
Normal file
1
treepuncher/modules/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .module import LogicModule
|
81
treepuncher/modules/core.py
Normal file
81
treepuncher/modules/core.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
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
|
||||
|
11
treepuncher/modules/module.py
Normal file
11
treepuncher/modules/module.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
|
||||
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
|
||||
|
24
treepuncher/notifier.py
Normal file
24
treepuncher/notifier.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from typing import Callable, List
|
||||
|
||||
class Notifier:
|
||||
_report_functions : List[Callable]
|
||||
|
||||
def __init__(self):
|
||||
self._report_functions = []
|
||||
|
||||
def register(self, fn:Callable):
|
||||
self._report_functions.append(fn)
|
||||
return fn
|
||||
|
||||
def report(self) -> str:
|
||||
return '\n'.join(str(fn()).strip() for fn in self._report_functions)
|
||||
|
||||
def notify(self, text, log:bool = False, **kwargs):
|
||||
print(text)
|
||||
|
||||
async def initialize(self, _client:'Treepuncher'):
|
||||
pass
|
||||
|
||||
async def cleanup(self, _client:'Treepuncher'):
|
||||
pass
|
||||
|
0
treepuncher/py.typed
Normal file
0
treepuncher/py.typed
Normal file
292
treepuncher/treepuncher.py
Normal file
292
treepuncher/treepuncher.py
Normal file
|
@ -0,0 +1,292 @@
|
|||
import re
|
||||
import logging
|
||||
import asyncio
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from typing import List, Dict, Union, Optional, Any, Type
|
||||
from enum import Enum
|
||||
|
||||
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 .notifier import Notifier
|
||||
from .events import ChatEvent
|
||||
from .events.chat import MessageType
|
||||
from .modules.module import LogicModule
|
||||
|
||||
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
|
||||
|
||||
class TreepuncherEvents(Enum):
|
||||
DIED = 0
|
||||
IN_GAME = 1
|
||||
|
||||
class Treepuncher(MinecraftClient):
|
||||
in_game : bool
|
||||
gamemode : Gamemode
|
||||
dimension : Dimension
|
||||
difficulty : Difficulty
|
||||
join_time : datetime.datetime
|
||||
|
||||
hp : float
|
||||
food : float
|
||||
xp : float
|
||||
lvl : int
|
||||
total_xp : int
|
||||
|
||||
slot : int
|
||||
inventory : List[Item]
|
||||
# TODO inventory
|
||||
|
||||
position : BlockPos
|
||||
# TODO world
|
||||
|
||||
tablist : Dict[uuid.UUID, dict]
|
||||
|
||||
# TODO player abilities
|
||||
# walk_speed : float
|
||||
# fly_speed : float
|
||||
# flags : int
|
||||
|
||||
notifier : Notifier
|
||||
scheduler : AsyncIOScheduler
|
||||
modules : List[LogicModule]
|
||||
ctx : Dict[Any, Any]
|
||||
|
||||
def __init__(self, *args, notifier:Notifier=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.hp = 20.0
|
||||
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.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]
|
||||
|
||||
async def start(self):
|
||||
await self.notifier.initialize(self)
|
||||
for m in self.modules:
|
||||
await m.initialize(self)
|
||||
await super().start()
|
||||
self.scheduler.resume()
|
||||
|
||||
async def stop(self, force:bool=False):
|
||||
self.scheduler.pause()
|
||||
await super().stop(force=force)
|
||||
for m in self.modules:
|
||||
await m.cleanup(self)
|
||||
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
|
||||
|
||||
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