Compare commits
6 commits
Author | SHA1 | Date | |
---|---|---|---|
22ae0b9f3a | |||
9ac4a844cf | |||
e8c917fcf3 | |||
5672a4ad36 | |||
0ef56df704 | |||
b0b0e2dcfa |
43 changed files with 644 additions and 1600 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 type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
# Auto generated version file
|
|
||||||
src/treepuncher/__version__.py
|
|
||||||
|
|
111
README.md
111
README.md
|
@ -1,111 +1,2 @@
|
||||||
# treepuncher
|
# treepuncher
|
||||||
an hackable headless Minecraft client, built with **[aiocraft](https://git.alemi.dev/aiocraft.git/about)**
|
An hackable Minecraft client, built with aiocraft
|
||||||
|
|
||||||
### 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!
|
|
||||||
|
|
||||||
|
|
|
@ -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 aiocraft.util.helpers import parse_chat
|
||||||
|
|
||||||
from .base import BaseEvent
|
|
||||||
|
|
||||||
CHAT_MESSAGE_MATCHER = re.compile(r"<(?P<usr>[A-Za-z0-9_]+)> (?P<msg>.+)")
|
CHAT_MESSAGE_MATCHER = re.compile(r"<(?P<usr>[A-Za-z0-9_]+)> (?P<msg>.+)")
|
||||||
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
|
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
|
||||||
WHISPER_MATCHER = re.compile(r"(?:to (?P<touser>[A-Za-z0-9_]+)( |):|(?P<fromuser>[A-Za-z0-9_]+) whispers( |):|from (?P<from9b>[A-Za-z0-9_]+):) (?P<txt>.+)", flags=re.IGNORECASE)
|
WHISPER_MATCHER = re.compile(r"(?:to (?P<touser>[A-Za-z0-9_]+)( |):|(?P<fromuser>[A-Za-z0-9_]+) whispers( |):|from (?P<from9b>[A-Za-z0-9_]+):) (?P<txt>.+)", flags=re.IGNORECASE)
|
||||||
|
@ -21,7 +19,7 @@ class MessageType(Enum):
|
||||||
LEAVE = "leave"
|
LEAVE = "leave"
|
||||||
SYSTEM = "system"
|
SYSTEM = "system"
|
||||||
|
|
||||||
class ChatEvent(BaseEvent):
|
class ChatEvent:
|
||||||
text : str
|
text : str
|
||||||
type : MessageType
|
type : MessageType
|
||||||
user : str
|
user : str
|
||||||
|
@ -54,7 +52,6 @@ class ChatEvent(BaseEvent):
|
||||||
|
|
||||||
match = JOIN_LEAVE_MATCHER.search(self.text)
|
match = JOIN_LEAVE_MATCHER.search(self.text)
|
||||||
if match:
|
if match:
|
||||||
self.type = MessageType.JOIN if match["action"] == "joined" else MessageType.LEAVE
|
self.type = MessageType.JOIN if match["action"] == "join" else MessageType.LEAVE
|
||||||
self.message = "joined" if self.type == MessageType.JOIN else "left"
|
|
||||||
self.user = match["usr"]
|
self.user = match["usr"]
|
||||||
return
|
return
|
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
|
||||||
|
|
0
treepuncher/py.typed
Normal file
0
treepuncher/py.typed
Normal file
315
treepuncher/treepuncher.py
Normal file
315
treepuncher/treepuncher.py
Normal file
|
@ -0,0 +1,315 @@
|
||||||
|
import io
|
||||||
|
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.types import Context
|
||||||
|
from aiocraft.mc.definitions import Difficulty, Dimension, Gamemode, BlockPos
|
||||||
|
|
||||||
|
from aiocraft.mc.proto.play.clientbound import (
|
||||||
|
PacketRespawn, PacketLogin, PacketPosition, PacketUpdateHealth, PacketExperience, PacketSetSlot,
|
||||||
|
PacketAbilities, PacketPlayerInfo, PacketMapChunk, PacketBlockChange, PacketMultiBlockChange,
|
||||||
|
PacketChat as PacketChatMessage, PacketHeldItemSlot as PacketHeldItemChange
|
||||||
|
)
|
||||||
|
from aiocraft.mc.proto.play.serverbound import (
|
||||||
|
PacketTeleportConfirm, PacketClientCommand, PacketSettings, PacketChat,
|
||||||
|
PacketHeldItemSlot
|
||||||
|
)
|
||||||
|
|
||||||
|
from .events import ChatEvent
|
||||||
|
from .events.chat import MessageType
|
||||||
|
from .modules.module import LogicModule
|
||||||
|
from .world.chunk import World, Chunk
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
hp : float
|
||||||
|
food : float
|
||||||
|
xp : float
|
||||||
|
lvl : int
|
||||||
|
total_xp : int
|
||||||
|
|
||||||
|
slot : int
|
||||||
|
inventory : List[dict]
|
||||||
|
# TODO inventory
|
||||||
|
|
||||||
|
position : BlockPos
|
||||||
|
world : World
|
||||||
|
# TODO world
|
||||||
|
|
||||||
|
tablist : Dict[uuid.UUID, dict]
|
||||||
|
|
||||||
|
# TODO player abilities
|
||||||
|
# walk_speed : float
|
||||||
|
# fly_speed : float
|
||||||
|
# flags : int
|
||||||
|
|
||||||
|
scheduler : AsyncIOScheduler
|
||||||
|
modules : List[LogicModule]
|
||||||
|
ctx : Dict[Any, Any]
|
||||||
|
|
||||||
|
def __init__(self, *args, **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.hp = 20.0
|
||||||
|
self.food = 20.0
|
||||||
|
self.xp = 0.0
|
||||||
|
self.lvl = 0
|
||||||
|
|
||||||
|
self.slot = 0
|
||||||
|
self.inventory = [ {} for _ in range(46) ]
|
||||||
|
|
||||||
|
self.position = BlockPos(0, 0, 0)
|
||||||
|
self.world = World()
|
||||||
|
|
||||||
|
self.tablist = {}
|
||||||
|
|
||||||
|
self.modules = []
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
self._register_handlers()
|
||||||
|
|
||||||
|
@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[dict]:
|
||||||
|
return self.inventory[36:45]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected(self) -> dict:
|
||||||
|
return self.hotbar[self.slot]
|
||||||
|
|
||||||
|
async def start(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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
return self.register(PacketChatMessage, process_chat_packet)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
@self.on_packet(PacketMapChunk)
|
||||||
|
async def process_chunk_packet(packet:PacketMapChunk):
|
||||||
|
chunk = Chunk(packet.x, packet.z, packet.bitMap, ground_up_continuous=packet.groundUp)
|
||||||
|
# self._logger.info("Processing chunk buffer of length %d", len(packet.chunkData))
|
||||||
|
chunk.read(io.BytesIO(packet.chunkData), Context(overworld=self.dimension == Dimension.OVERWORLD))
|
||||||
|
self.world.put(chunk, x=packet.x, z=packet.z)
|
||||||
|
|
||||||
|
@self.scheduler.scheduled_job('interval', seconds=15)
|
||||||
|
async def check_blocks_under_self():
|
||||||
|
block = int(self.world.get(self.position.x, self.position.y, self.position.z))
|
||||||
|
self._logger.info("Player block: %d:%d", block>>4, block&0xF)
|
||||||
|
block = int(self.world.get(self.position.x, self.position.y + 1, self.position.z))
|
||||||
|
self._logger.info("Player block + 1: %d:%d", block>>4, block&0xF)
|
||||||
|
block = int(self.world.get(self.position.x, self.position.y + 2, self.position.z))
|
||||||
|
self._logger.info("Player block + 2: %d:%d", block>>4, block&0xF)
|
||||||
|
out = ""
|
||||||
|
for y in range(-2, 3):
|
||||||
|
for z in range(-5, 6):
|
||||||
|
for x in range(-5, 6):
|
||||||
|
block = int(self.world.get(self.position.x + x, self.position.y + y, self.position.z + z))
|
||||||
|
out += f"{block>>4}:{block&0xF} "
|
||||||
|
out += "\n"
|
||||||
|
out += "\n\n"
|
||||||
|
self._logger.info(out)
|
||||||
|
|
||||||
|
|
202
treepuncher/world/chunk.py
Normal file
202
treepuncher/world/chunk.py
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from typing import Dict, Tuple, Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from aiocraft.mc.types import VarInt, Short, UnsignedByte, Type, Context
|
||||||
|
|
||||||
|
class BitStream:
|
||||||
|
data : bytes
|
||||||
|
cursor : int
|
||||||
|
size : int
|
||||||
|
|
||||||
|
def __init__(self, data:bytes, size:int):
|
||||||
|
self.data = data
|
||||||
|
self.cursor = 0
|
||||||
|
self.size = size if size > 0 else len(self.data) * 8
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self.size - self.cursor
|
||||||
|
|
||||||
|
def read(self, size:int) -> int:
|
||||||
|
if len(self) < size:
|
||||||
|
raise ValueError(f"Not enough bits ({len(self)} left, {size} requested)")
|
||||||
|
# Calculate splice indexes
|
||||||
|
start_byte = (self.cursor//8)
|
||||||
|
end_byte = math.ceil((self.cursor + size) / 8)
|
||||||
|
# Construct int from bytes
|
||||||
|
buf = int.from_bytes(
|
||||||
|
self.data[start_byte:end_byte],
|
||||||
|
byteorder='little', signed=False
|
||||||
|
)
|
||||||
|
# Trim extra bytes
|
||||||
|
end_offset = (self.cursor + size) % 8
|
||||||
|
if end_offset > 0:
|
||||||
|
buf = buf >> (8 - end_offset) # There's an extra 1 to the left in air, maybe shift 1 bit less?
|
||||||
|
start_offset = self.cursor % 8
|
||||||
|
buf = buf & (( 1 << size ) - 1)
|
||||||
|
# Increment and return
|
||||||
|
self.cursor += size
|
||||||
|
return buf
|
||||||
|
|
||||||
|
class PalettedContainer(Type):
|
||||||
|
pytype : type
|
||||||
|
threshold : int
|
||||||
|
size : int
|
||||||
|
|
||||||
|
def __init__(self, threshold:int, size:int):
|
||||||
|
self.threshold = threshold
|
||||||
|
self.size = size
|
||||||
|
|
||||||
|
def write(self, data, buffer:io.BytesIO, ctx:Context):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def read(self, buffer:io.BytesIO, ctx:Context):
|
||||||
|
bits = UnsignedByte.read(buffer, ctx=ctx)
|
||||||
|
logging.info("Bits per block : %d", bits)
|
||||||
|
if bits < 4:
|
||||||
|
bits = 4
|
||||||
|
if bits >= self.threshold:
|
||||||
|
bits = 13 # this should not be hardcoded but we have no way to calculate all possible block states
|
||||||
|
palette_len = VarInt.read(buffer, ctx=ctx)
|
||||||
|
palette = np.zeros((palette_len,), dtype='int32')
|
||||||
|
for i in range(palette_len):
|
||||||
|
palette[i] = VarInt.read(buffer, ctx=ctx)
|
||||||
|
container_size = VarInt.read(buffer, ctx=ctx)
|
||||||
|
stream = BitStream(buffer.read(container_size * 8), container_size*8*8) # a Long is 64 bits long
|
||||||
|
section = np.zeros((self.size, self.size, self.size), dtype='int32')
|
||||||
|
for y in range(self.size):
|
||||||
|
for z in range(self.size):
|
||||||
|
for x in range(self.size):
|
||||||
|
val = stream.read(bits)
|
||||||
|
if bits > 4:
|
||||||
|
if val >= len(palette):
|
||||||
|
logging.warning("out of bounds : %d (%d)", val, len(palette))
|
||||||
|
section[x, y, z] = val
|
||||||
|
continue
|
||||||
|
logging.info("Reading index when bits > 4")
|
||||||
|
section[x, y, z] = palette[val] if bits < self.threshold else val
|
||||||
|
return section
|
||||||
|
|
||||||
|
BiomeContainer = PalettedContainer(4, 4)
|
||||||
|
BlockStateContainer = PalettedContainer(9, 16)
|
||||||
|
|
||||||
|
class HalfByteArrayType(Type):
|
||||||
|
size : int
|
||||||
|
|
||||||
|
def __init__(self, size:int):
|
||||||
|
self.size = size
|
||||||
|
|
||||||
|
def write(self, data, buffer:io.BytesIO, ctx:Context):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def read(self, buffer:io.BytesIO, ctx:Context):
|
||||||
|
section = np.empty((self.size, self.size, self.size), dtype='int32')
|
||||||
|
bit_buffer = BitStream(buffer.read((self.size**3)//2), (self.size**3)*4)
|
||||||
|
for y in range(self.size):
|
||||||
|
for z in range(self.size):
|
||||||
|
for x in range(self.size):
|
||||||
|
section[x, y, z] = bit_buffer.read(4)
|
||||||
|
return section
|
||||||
|
|
||||||
|
BlockLightSection = HalfByteArrayType(16)
|
||||||
|
SkyLightSection = HalfByteArrayType(16)
|
||||||
|
|
||||||
|
class NewChunkSectionType(Type):
|
||||||
|
pytype : type
|
||||||
|
|
||||||
|
def write(self, data, buffer:io.BytesIO, ctx:Context):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def read(self, buffer:io.BytesIO, ctx:Context):
|
||||||
|
block_count = Short.read(buffer, ctx=ctx)
|
||||||
|
block_states = BlockStateContainer.read(buffer, ctx=ctx)
|
||||||
|
biomes = BiomeContainer.read(buffer, ctx=ctx)
|
||||||
|
return (
|
||||||
|
block_count,
|
||||||
|
block_states,
|
||||||
|
biomes
|
||||||
|
)
|
||||||
|
|
||||||
|
class OldChunkSectionType(Type):
|
||||||
|
pytype : type
|
||||||
|
|
||||||
|
def write(self, data, buffer:io.BytesIO, ctx:Context):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def read(self, buffer:io.BytesIO, ctx:Context):
|
||||||
|
block_states = BlockStateContainer.read(buffer, ctx=ctx)
|
||||||
|
block_light = BlockLightSection.read(buffer, ctx=ctx)
|
||||||
|
if ctx.overworld:
|
||||||
|
sky_light = SkyLightSection.read(buffer, ctx=ctx)
|
||||||
|
else:
|
||||||
|
sky_light = np.empty((16, 16, 16), dtype='int32')
|
||||||
|
return (
|
||||||
|
block_states,
|
||||||
|
block_light,
|
||||||
|
sky_light
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ChunkSection = OldChunkSectionType()
|
||||||
|
|
||||||
|
class Chunk(Type):
|
||||||
|
x : int
|
||||||
|
z : int
|
||||||
|
bitmask : int
|
||||||
|
ground_up_continuous : bool
|
||||||
|
blocks : np.ndarray
|
||||||
|
block_light : np.ndarray
|
||||||
|
sky_light : np.ndarray
|
||||||
|
biomes: bytes
|
||||||
|
|
||||||
|
def __init__(self, x:int, z:int, bitmask:int, ground_up_continuous:bool):
|
||||||
|
self.x = x
|
||||||
|
self.z = z
|
||||||
|
self.bitmask = bitmask
|
||||||
|
self.blocks = np.zeros((16, 256, 16), dtype='int32')
|
||||||
|
self.block_light = np.zeros((16, 256, 16), dtype='int32')
|
||||||
|
self.sky_light = np.zeros((16, 256, 16), dtype='int32')
|
||||||
|
self.ground_up_continuous = ground_up_continuous
|
||||||
|
|
||||||
|
def __getitem__(self, item:Any):
|
||||||
|
return self.blocks[item]
|
||||||
|
|
||||||
|
def read(self, buffer:io.BytesIO, ctx:Context):
|
||||||
|
logging.info("Reading chunk")
|
||||||
|
for i in range(16):
|
||||||
|
if (self.bitmask >> i) & 1:
|
||||||
|
logging.info("Reading section #%d", i)
|
||||||
|
block_states, block_light, sky_light = ChunkSection.read(buffer, ctx=ctx)
|
||||||
|
self.blocks[:, i*16 : (i+1)*16, :] = block_states
|
||||||
|
self.block_light[:, i*16 : (i+1)*16, :] = block_light
|
||||||
|
self.sky_light[:, i*16 : (i+1)*16, :] = sky_light
|
||||||
|
if self.ground_up_continuous:
|
||||||
|
self.biomes = buffer.read(256) # 16x16
|
||||||
|
if buffer.read():
|
||||||
|
logging.warning("Leftover data in chunk buffer")
|
||||||
|
return self
|
||||||
|
|
||||||
|
class World:
|
||||||
|
chunks : Dict[Tuple[int, int], Chunk]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.chunks = {}
|
||||||
|
|
||||||
|
def __getitem__(self, item:Tuple[int, int, int]):
|
||||||
|
return self.get(*item)
|
||||||
|
|
||||||
|
def get(self, x:int, y:int, z:int) -> int:
|
||||||
|
coord = (x//16, z//16)
|
||||||
|
if coord not in self.chunks:
|
||||||
|
raise KeyError(f"Chunk {coord} not loaded")
|
||||||
|
return self.chunks[coord][int(x%16), int(y), int(z%16)]
|
||||||
|
|
||||||
|
def put(self, chunk:Chunk, x:int, z:int):
|
||||||
|
self.chunks[(x,z)] = chunk
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue