Compare commits
145 commits
Author | SHA1 | Date | |
---|---|---|---|
aaf36bdf5f | |||
4c333c7852 | |||
|
5be33deb81 | ||
|
9ec38961f5 | ||
8101a255ad | |||
783a619bae | |||
eb723f76f5 | |||
71d5f0363f | |||
39fea517cb | |||
f9c97c0c34 | |||
bd97b891f4 | |||
5586d1fc9c | |||
ff39260fa4 | |||
f0a4fe9872 | |||
caeb5215cc | |||
56065d62f8 | |||
989731574a | |||
f97bf06b5a | |||
0ed7f26c7d | |||
0b4847fb9c | |||
99a4a78dcb | |||
f920bbc838 | |||
e6a1ecc5c4 | |||
82cd2fbf1d | |||
8655c5ebde | |||
ef8ffac391 | |||
bcdb5a6694 | |||
36a2fece2f | |||
58ab0e0378 | |||
f5256da6bf | |||
98c82bcbcf | |||
e20f7a967f | |||
3b7aa42e0b | |||
aa0e804965 | |||
b750aa140f | |||
ba721a4b30 | |||
b14ca9d862 | |||
1499cab317 | |||
f5481ace65 | |||
8f3f8fd69b | |||
5465837478 | |||
bcf293a8ad | |||
2ad89a61d2 | |||
425a7c7f71 | |||
f04d873d21 | |||
acdb60cd2f | |||
c2ea55c699 | |||
aa6f2b03bb | |||
f6ee4b0d79 | |||
312f132c50 | |||
5abfb63bd3 | |||
0540011bb6 | |||
d8c62e57d2 | |||
363eeea3aa | |||
011531b1a4 | |||
48590d56bd | |||
a563984e48 | |||
61811f542b | |||
d54fb380e9 | |||
664cb64613 | |||
4527b866bf | |||
76c84006ad | |||
847bde3d79 | |||
6364f52d0b | |||
d2a13360a8 | |||
4c6d906d3a | |||
79886a2c64 | |||
5f2beac41b | |||
3067032f1b | |||
f95f594967 | |||
fdfe71b481 | |||
bbe668bcec | |||
3548603578 | |||
b8477c3d22 | |||
2885c3c270 | |||
98d3967dfe | |||
c2a9726ec6 | |||
c29a03aeda | |||
757be795c3 | |||
8b9848cdfb | |||
1ecb1c44e8 | |||
8503a36df4 | |||
7c4c523562 | |||
cfd5dd079a | |||
1307869d9c | |||
aeea271547 | |||
61cfd72d1f | |||
a3aacd6291 | |||
|
b92791ce04 | ||
91afbbc28a | |||
f0c34376fa | |||
4e4bb50e4a | |||
b73a352484 | |||
e52b4d5a37 | |||
261d18f854 | |||
086e59a0ce | |||
977ce7a9de | |||
d5d1f8c036 | |||
15c2fc09dc | |||
5a652f1710 | |||
6f53778fc9 | |||
eefa1bf211 | |||
6a39e61e03 | |||
c6ff6a6bf7 | |||
5527d50ff3 | |||
c87dbd63e2 | |||
923dfa3532 | |||
cdc5e9301f | |||
fa4f0dc58a | |||
c0f698ac7d | |||
4da1a9f834 | |||
6d64973c54 | |||
db01f5d5b7 | |||
148c809628 | |||
6813181800 | |||
49fe4f185f | |||
631ff0bc2c | |||
3b9880ad6b | |||
7b7c46e184 | |||
7b5958dde3 | |||
ab2170e669 | |||
9d7badba5d | |||
280d97afc8 | |||
e18c725a6b | |||
28f84ca095 | |||
aebe8c1965 | |||
ab9d45c244 | |||
814e489142 | |||
a29cd737e8 | |||
d53272e411 | |||
016e4170df | |||
af4cf7db90 | |||
c9f65f56aa | |||
|
e2459f7982 | ||
f04cf02b4e | |||
fafb19a7a2 | |||
a5169f40cc | |||
88bfadd8cd | |||
b41f2f6074 | |||
790f5cbb1f | |||
39dcfc4245 | |||
d12b4b4f88 | |||
d5934da832 | |||
78b97a42a6 | |||
4b1b508be9 |
43 changed files with 1598 additions and 442 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# 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,3 +127,6 @@ 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,2 +1,111 @@
|
||||||
# treepuncher
|
# treepuncher
|
||||||
An hackable Minecraft client, built with aiocraft
|
an hackable headless Minecraft client, built with **[aiocraft](https://git.alemi.dev/aiocraft.git/about)**
|
||||||
|
|
||||||
|
### Features
|
||||||
|
* persistent storage
|
||||||
|
* configuration file
|
||||||
|
* pluggable plugin system
|
||||||
|
* event system with callbacks
|
||||||
|
* world processing
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
`treepuncher` is still in development and thus not available yet on PyPI, to install it fetch directly from git:
|
||||||
|
* `pip install "git+https://git.alemi.dev/treepuncher.git@v0.3.0"`
|
||||||
|
|
||||||
|
currently only 1.16.5 is being targeted, so while simple things _should_ work on any version, more complex interactions may break outside 1.16.5
|
||||||
|
|
||||||
|
`treepuncher` can both be run as a pluggable CLI application or as a library, depending on how much you need to customize its behaviour
|
||||||
|
|
||||||
|
### as an application
|
||||||
|
`treepuncher` ships as a standalone CLI application which you can run with `python -m treepuncher`
|
||||||
|
|
||||||
|
* prepare a runtime directory with this layout:
|
||||||
|
```
|
||||||
|
.
|
||||||
|
|- log/ # will contain rotating log files: MYBOT.log, MYBOT.log.1 ...
|
||||||
|
|- data/ # will contain session files: MYBOT.session
|
||||||
|
|- addons/ # put your addons here
|
||||||
|
|- MYBOT.ini # your configuration file for one session
|
||||||
|
```
|
||||||
|
|
||||||
|
* create your first addon (for example, a simple chat logger) inside `./addons/chat_logger.py`
|
||||||
|
```py
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from treepuncher import Addon, ConfigObject
|
||||||
|
from treepuncher.events import ChatEvent
|
||||||
|
|
||||||
|
class ChatLogger(Addon):
|
||||||
|
@dataclass # must be a dataclass
|
||||||
|
class Options(ConfigObject): # must extend ConfigObject
|
||||||
|
prefix : str = ""
|
||||||
|
config : Options # must add this type annotation
|
||||||
|
|
||||||
|
def register(self): # register all callbacks and schedulers in here
|
||||||
|
@self.client.on(ChatEvent)
|
||||||
|
async def print_chat(event: ChatEvent):
|
||||||
|
print(f"{event.user} >> {event.text})
|
||||||
|
```
|
||||||
|
* create a config file for your session (for example, `MYBOT`): `MYBOT.ini`
|
||||||
|
```ini
|
||||||
|
[Treepuncher]
|
||||||
|
server = your.server.com
|
||||||
|
username = your_account_username
|
||||||
|
client_id = your_microsoft_authenticator_client_id
|
||||||
|
client_secret = your_microsoft_authenticator_client_secret
|
||||||
|
code = microsoft_auth_code
|
||||||
|
|
||||||
|
; you must specify the addon section to have it loaded,
|
||||||
|
; even if it doesn't take any config value
|
||||||
|
[ChatLogger]
|
||||||
|
prefix = CHAT |::
|
||||||
|
```
|
||||||
|
* run the treepuncher client : `python -m treepuncher MYBOT` (note that session name must be same as config file, minus `.ini`)
|
||||||
|
|
||||||
|
### as a library
|
||||||
|
under the hood `treepuncher` is just a library and it's possible to invoke it programmatically
|
||||||
|
* instantiate the `treepuncher` object
|
||||||
|
```py
|
||||||
|
from treepuncher import Treepuncher
|
||||||
|
|
||||||
|
client = Treepuncher(
|
||||||
|
"my_bot",
|
||||||
|
server="your.server.com",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
* prepare your addons (must extend `treepuncher.Addon`) and install them
|
||||||
|
```py
|
||||||
|
from treepuncher import Addon
|
||||||
|
|
||||||
|
class MyAddon(Addon):
|
||||||
|
pass
|
||||||
|
|
||||||
|
addon = MyAddon()
|
||||||
|
client.install(addon)
|
||||||
|
```
|
||||||
|
* run your client
|
||||||
|
```py
|
||||||
|
client.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
`treepuncher` supports both legacy Yggdrasil authentication (with options to override session and auth server) and modern Microsoft OAuth authentication. It will store the auth token inside a session file, to restart without requiring credentials again
|
||||||
|
|
||||||
|
to be able to use Microsoft authentication you will need to register an Azure application (see [community](https://wiki.vg/Microsoft_Authentication_Scheme) and [microsoft](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) docs on how to do that).
|
||||||
|
|
||||||
|
this is a tedious process but can be done just once for many accounts, sadly Microsoft decided that only kids play minecraft and we developers should just suffer...
|
||||||
|
|
||||||
|
**be warned that Microsoft may limit your account if they find your activity suspicious**
|
||||||
|
|
||||||
|
once you have your `client_id` and `client_secret` use [this page](https://fantabos.co/msauth) to generate a login code: put in your `client_id` and any state and press `auth`.
|
||||||
|
you will be brought to Microsoft login page, input your credentials, authorize your application and you will be redirected back to the `msauth` page, but now there should be a code in the `auth code` field
|
||||||
|
|
||||||
|
put this code in your config and you're good to go!
|
||||||
|
|
||||||
|
if you'd rather use classic Yggdrasil authentication, consider [ftbsc yggdrasil](https://yggdrasil.fantabos.co) ([src](https://git.fantabos.co/yggdrasil))
|
||||||
|
|
||||||
|
legacy Yggdrasil authentication supports both an hardcoded password or a pre-authorized access token
|
||||||
|
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
development is managed by [ftbsc](https://fantabos.co), mostly on [our git](https://git.fantabos.co). If you'd like to contribute, get in contact with any of us using any available channel!
|
||||||
|
|
||||||
|
|
30
pyproject.toml
Normal file
30
pyproject.toml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
[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"
|
|
@ -1,2 +0,0 @@
|
||||||
apscheduler
|
|
||||||
aiocraft
|
|
21
scripts/migrate-old-documents-to-namespaced-documents.py
Normal file
21
scripts/migrate-old-documents-to-namespaced-documents.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
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
27
setup.py
|
@ -1,27 +0,0 @@
|
||||||
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',
|
|
||||||
],
|
|
||||||
)
|
|
4
src/treepuncher/__init__.py
Normal file
4
src/treepuncher/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from .scaffold import ConfigObject
|
||||||
|
from .treepuncher import Treepuncher
|
||||||
|
from .addon import Addon
|
||||||
|
from .notifier import Notifier, Provider
|
130
src/treepuncher/__main__.py
Normal file
130
src/treepuncher/__main__.py
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
#!/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()
|
||||||
|
|
||||||
|
|
102
src/treepuncher/addon.py
Normal file
102
src/treepuncher/addon.py
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
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
|
6
src/treepuncher/events/__init__.py
Normal file
6
src/treepuncher/events/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
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
|
2
src/treepuncher/events/base.py
Normal file
2
src/treepuncher/events/base.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class BaseEvent:
|
||||||
|
pass
|
13
src/treepuncher/events/block_update.py
Normal file
13
src/treepuncher/events/block_update.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
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
|
|
@ -5,6 +5,8 @@ from enum import Enum
|
||||||
|
|
||||||
from aiocraft.util.helpers import parse_chat
|
from aiocraft.util.helpers import parse_chat
|
||||||
|
|
||||||
|
from .base import BaseEvent
|
||||||
|
|
||||||
CHAT_MESSAGE_MATCHER = re.compile(r"<(?P<usr>[A-Za-z0-9_]+)> (?P<msg>.+)")
|
CHAT_MESSAGE_MATCHER = re.compile(r"<(?P<usr>[A-Za-z0-9_]+)> (?P<msg>.+)")
|
||||||
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
|
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
|
||||||
WHISPER_MATCHER = re.compile(r"(?:to (?P<touser>[A-Za-z0-9_]+)( |):|(?P<fromuser>[A-Za-z0-9_]+) whispers( |):|from (?P<from9b>[A-Za-z0-9_]+):) (?P<txt>.+)", flags=re.IGNORECASE)
|
WHISPER_MATCHER = re.compile(r"(?:to (?P<touser>[A-Za-z0-9_]+)( |):|(?P<fromuser>[A-Za-z0-9_]+) whispers( |):|from (?P<from9b>[A-Za-z0-9_]+):) (?P<txt>.+)", flags=re.IGNORECASE)
|
||||||
|
@ -19,7 +21,7 @@ class MessageType(Enum):
|
||||||
LEAVE = "leave"
|
LEAVE = "leave"
|
||||||
SYSTEM = "system"
|
SYSTEM = "system"
|
||||||
|
|
||||||
class ChatEvent:
|
class ChatEvent(BaseEvent):
|
||||||
text : str
|
text : str
|
||||||
type : MessageType
|
type : MessageType
|
||||||
user : str
|
user : str
|
14
src/treepuncher/events/connection.py
Normal file
14
src/treepuncher/events/connection.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
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
|
4
src/treepuncher/events/death.py
Normal file
4
src/treepuncher/events/death.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from .base import BaseEvent
|
||||||
|
|
||||||
|
class DeathEvent(BaseEvent):
|
||||||
|
SENTINEL = object()
|
15
src/treepuncher/events/join_game.py
Normal file
15
src/treepuncher/events/join_game.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
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
|
8
src/treepuncher/events/packet.py
Normal file
8
src/treepuncher/events/packet.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from aiocraft.packet import Packet
|
||||||
|
|
||||||
|
from .base import BaseEvent
|
||||||
|
|
||||||
|
class PacketEvent(BaseEvent):
|
||||||
|
packet : Packet
|
||||||
|
def __init__(self, p:Packet):
|
||||||
|
self.packet = p
|
8
src/treepuncher/events/system.py
Normal file
8
src/treepuncher/events/system.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from .base import BaseEvent
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectedEvent(BaseEvent):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DisconnectedEvent(BaseEvent):
|
||||||
|
pass
|
6
src/treepuncher/game/__init__.py
Normal file
6
src/treepuncher/game/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from .state import GameState
|
||||||
|
from .inventory import GameInventory
|
||||||
|
from .tablist import GameTablist
|
||||||
|
from .chat import GameChat
|
||||||
|
from .world import GameWorld
|
||||||
|
from .container import GameContainer
|
23
src/treepuncher/game/chat.py
Normal file
23
src/treepuncher/game/chat.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
87
src/treepuncher/game/container.py
Normal file
87
src/treepuncher/game/container.py
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
39
src/treepuncher/game/inventory.py
Normal file
39
src/treepuncher/game/inventory.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
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
|
150
src/treepuncher/game/state.py
Normal file
150
src/treepuncher/game/state.py
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
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
|
||||||
|
|
52
src/treepuncher/game/tablist.py
Normal file
52
src/treepuncher/game/tablist.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
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)))
|
||||||
|
|
||||||
|
|
||||||
|
|
128
src/treepuncher/game/world.py
Normal file
128
src/treepuncher/game/world.py
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
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)
|
44
src/treepuncher/helpers.py
Normal file
44
src/treepuncher/helpers.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
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)
|
59
src/treepuncher/notifier.py
Normal file
59
src/treepuncher/notifier.py
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
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)
|
||||||
|
)
|
65
src/treepuncher/scaffold.py
Normal file
65
src/treepuncher/scaffold.py
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
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
|
||||||
|
|
116
src/treepuncher/storage.py
Normal file
116
src/treepuncher/storage.py
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
3
src/treepuncher/traits/__init__.py
Normal file
3
src/treepuncher/traits/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from .callbacks import CallbacksHolder
|
||||||
|
from .runnable import Runnable
|
||||||
|
|
50
src/treepuncher/traits/callbacks.py
Normal file
50
src/treepuncher/traits/callbacks.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
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()))
|
||||||
|
|
50
src/treepuncher/traits/runnable.py
Normal file
50
src/treepuncher/traits/runnable.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
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")
|
||||||
|
|
243
src/treepuncher/treepuncher.py
Normal file
243
src/treepuncher/treepuncher.py
Normal file
|
@ -0,0 +1,243 @@
|
||||||
|
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 +0,0 @@
|
||||||
from .treepuncher import Treepuncher
|
|
|
@ -1 +0,0 @@
|
||||||
from .chat import ChatEvent
|
|
|
@ -1 +0,0 @@
|
||||||
from .module import LogicModule
|
|
|
@ -1,81 +0,0 @@
|
||||||
from ..treepuncher import Treepuncher, TreepuncherEvents
|
|
||||||
from .module import LogicModule
|
|
||||||
|
|
||||||
from aiocraft.mc.proto.play.clientbound import (
|
|
||||||
PacketRespawn, PacketLogin, PacketPosition, PacketUpdateHealth, PacketExperience,
|
|
||||||
PacketAbilities, PacketChat as PacketChatMessage
|
|
||||||
)
|
|
||||||
from aiocraft.mc.proto.play.serverbound import PacketTeleportConfirm, PacketClientCommand, PacketChat
|
|
||||||
from aiocraft.mc.definitions import Difficulty, Dimension, Gamemode, Position
|
|
||||||
|
|
||||||
class CoreLogic(LogicModule):
|
|
||||||
def register(self, client:Treepuncher):
|
|
||||||
@client.on_disconnected()
|
|
||||||
async def on_disconnected():
|
|
||||||
client.in_game = False
|
|
||||||
|
|
||||||
@client.on_packet(PacketRespawn)
|
|
||||||
async def on_player_respawning(packet:PacketRespawn):
|
|
||||||
client.gamemode = Gamemode(packet.gamemode)
|
|
||||||
client.dimension = Dimension(packet.dimension)
|
|
||||||
client.difficulty = Difficulty(packet.difficulty)
|
|
||||||
if client.difficulty != Difficulty.PEACEFUL \
|
|
||||||
and client.gamemode != Gamemode.SPECTATOR:
|
|
||||||
client.in_game = True
|
|
||||||
else:
|
|
||||||
client.in_game = False
|
|
||||||
client._logger.info(
|
|
||||||
"Reloading world: %s (%s) in %s",
|
|
||||||
client.dimension.name,
|
|
||||||
client.difficulty.name,
|
|
||||||
client.gamemode.name
|
|
||||||
)
|
|
||||||
|
|
||||||
@client.on_packet(PacketLogin)
|
|
||||||
async def player_joining_cb(packet:PacketLogin):
|
|
||||||
client.gamemode = Gamemode(packet.gameMode)
|
|
||||||
client.dimension = Dimension(packet.dimension)
|
|
||||||
client.difficulty = Difficulty(packet.difficulty)
|
|
||||||
if client.difficulty != Difficulty.PEACEFUL \
|
|
||||||
and client.gamemode != Gamemode.SPECTATOR:
|
|
||||||
client.in_game = True
|
|
||||||
else:
|
|
||||||
client.in_game = False
|
|
||||||
client._logger.info(
|
|
||||||
"Joined world: %s (%s) in %s",
|
|
||||||
client.dimension.name,
|
|
||||||
client.difficulty.name,
|
|
||||||
client.gamemode.name
|
|
||||||
)
|
|
||||||
client.run_callbacks(TreepuncherEvents.IN_GAME)
|
|
||||||
|
|
||||||
@client.on_packet(PacketPosition)
|
|
||||||
async def player_rubberband_cb(packet:PacketPosition):
|
|
||||||
client._logger.info("Position synchronized")
|
|
||||||
client.position = Position(packet.x, packet.y, packet.z)
|
|
||||||
await client.dispatcher.write(
|
|
||||||
PacketTeleportConfirm(
|
|
||||||
client.dispatcher.proto,
|
|
||||||
teleportId=packet.teleportId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@client.on_packet(PacketUpdateHealth)
|
|
||||||
async def player_hp_cb(packet:PacketUpdateHealth):
|
|
||||||
if packet.health != client.hp and packet.health <= 0:
|
|
||||||
client._logger.info("Dead, respawning...")
|
|
||||||
await client.dispatcher.write(
|
|
||||||
PacketClientCommand(client.dispatcher.proto, actionId=0) # respawn
|
|
||||||
)
|
|
||||||
client.run_callbacks(TreepuncherEvents.DIED)
|
|
||||||
client.hp = packet.health
|
|
||||||
client.food = packet.food
|
|
||||||
|
|
||||||
@client.on_packet(PacketExperience)
|
|
||||||
async def player_xp_cb(packet:PacketExperience):
|
|
||||||
if packet.level != client.lvl:
|
|
||||||
client._logger.info("Level up : %d", packet.level)
|
|
||||||
client.xp = packet.experienceBar
|
|
||||||
client.lvl = packet.level
|
|
||||||
client.total_xp = packet.totalExperience
|
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
|
|
||||||
class LogicModule:
|
|
||||||
def register(self, client:'Treepuncher') -> None:
|
|
||||||
pass # override to register callbacks on client
|
|
||||||
|
|
||||||
async def initialize(self, client:'Treepuncher') -> None:
|
|
||||||
pass # override to register stuff on client start
|
|
||||||
|
|
||||||
async def cleanup(self, client:'Treepuncher') -> None:
|
|
||||||
pass # override to register stuff on client stop
|
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
from typing import Callable, List
|
|
||||||
|
|
||||||
class Notifier:
|
|
||||||
_report_functions : List[Callable]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._report_functions = []
|
|
||||||
|
|
||||||
def register(self, fn:Callable):
|
|
||||||
self._report_functions.append(fn)
|
|
||||||
return fn
|
|
||||||
|
|
||||||
def report(self) -> str:
|
|
||||||
return '\n'.join(str(fn()).strip() for fn in self._report_functions)
|
|
||||||
|
|
||||||
def notify(self, text, log:bool = False, **kwargs):
|
|
||||||
print(text)
|
|
||||||
|
|
||||||
async def initialize(self, _client:'Treepuncher'):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def cleanup(self, _client:'Treepuncher'):
|
|
||||||
pass
|
|
||||||
|
|
|
@ -1,292 +0,0 @@
|
||||||
import re
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from typing import List, Dict, Union, Optional, Any, Type
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
||||||
|
|
||||||
from aiocraft.client import MinecraftClient
|
|
||||||
from aiocraft.mc.packet import Packet
|
|
||||||
from aiocraft.mc.definitions import Difficulty, Dimension, Gamemode, BlockPos, Item
|
|
||||||
|
|
||||||
from aiocraft.mc.proto.play.clientbound import (
|
|
||||||
PacketRespawn, PacketLogin, PacketPosition, PacketUpdateHealth, PacketExperience, PacketSetSlot,
|
|
||||||
PacketAbilities, PacketPlayerInfo, PacketChat as PacketChatMessage, PacketHeldItemSlot as PacketHeldItemChange
|
|
||||||
)
|
|
||||||
from aiocraft.mc.proto.play.serverbound import (
|
|
||||||
PacketTeleportConfirm, PacketClientCommand, PacketSettings, PacketChat,
|
|
||||||
PacketHeldItemSlot
|
|
||||||
)
|
|
||||||
|
|
||||||
from .notifier import Notifier
|
|
||||||
from .events import ChatEvent
|
|
||||||
from .events.chat import MessageType
|
|
||||||
from .modules.module import LogicModule
|
|
||||||
|
|
||||||
REMOVE_COLOR_FORMATS = re.compile(r"§[0-9a-z]")
|
|
||||||
|
|
||||||
class TreepuncherEvents(Enum):
|
|
||||||
DIED = 0
|
|
||||||
IN_GAME = 1
|
|
||||||
|
|
||||||
class Treepuncher(MinecraftClient):
|
|
||||||
in_game : bool
|
|
||||||
gamemode : Gamemode
|
|
||||||
dimension : Dimension
|
|
||||||
difficulty : Difficulty
|
|
||||||
join_time : datetime.datetime
|
|
||||||
|
|
||||||
hp : float
|
|
||||||
food : float
|
|
||||||
xp : float
|
|
||||||
lvl : int
|
|
||||||
total_xp : int
|
|
||||||
|
|
||||||
slot : int
|
|
||||||
inventory : List[Item]
|
|
||||||
# TODO inventory
|
|
||||||
|
|
||||||
position : BlockPos
|
|
||||||
# TODO world
|
|
||||||
|
|
||||||
tablist : Dict[uuid.UUID, dict]
|
|
||||||
|
|
||||||
# TODO player abilities
|
|
||||||
# walk_speed : float
|
|
||||||
# fly_speed : float
|
|
||||||
# flags : int
|
|
||||||
|
|
||||||
notifier : Notifier
|
|
||||||
scheduler : AsyncIOScheduler
|
|
||||||
modules : List[LogicModule]
|
|
||||||
ctx : Dict[Any, Any]
|
|
||||||
|
|
||||||
def __init__(self, *args, notifier:Notifier=None, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.ctx = dict()
|
|
||||||
|
|
||||||
self.in_game = False
|
|
||||||
self.gamemode = Gamemode.SURVIVAL
|
|
||||||
self.dimension = Dimension.OVERWORLD
|
|
||||||
self.difficulty = Difficulty.HARD
|
|
||||||
self.join_time = datetime.datetime(2011, 11, 18)
|
|
||||||
|
|
||||||
self.hp = 20.0
|
|
||||||
self.food = 20.0
|
|
||||||
self.xp = 0.0
|
|
||||||
self.lvl = 0
|
|
||||||
|
|
||||||
self.slot = 0
|
|
||||||
self.inventory = [ Item() for _ in range(46) ]
|
|
||||||
|
|
||||||
self.position = BlockPos(0, 0, 0)
|
|
||||||
|
|
||||||
self.tablist = {}
|
|
||||||
|
|
||||||
self._register_handlers()
|
|
||||||
self.modules = []
|
|
||||||
|
|
||||||
self.notifier = notifier or Notifier()
|
|
||||||
tz = datetime.datetime.now(datetime.timezone.utc).astimezone().tzname() # APScheduler will complain if I don't specify a timezone...
|
|
||||||
self.scheduler = AsyncIOScheduler(timezone=tz)
|
|
||||||
logging.getLogger('apscheduler.executors.default').setLevel(logging.WARNING) # So it's way less spammy
|
|
||||||
self.scheduler.start(paused=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
if self.online_mode and self.token:
|
|
||||||
return self.token.selectedProfile.name
|
|
||||||
if not self.online_mode and self.username:
|
|
||||||
return self.username
|
|
||||||
raise ValueError("No token or username given")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def hotbar(self) -> List[Item]:
|
|
||||||
return self.inventory[36:45]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def selected(self) -> Item:
|
|
||||||
return self.hotbar[self.slot]
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
await self.notifier.initialize(self)
|
|
||||||
for m in self.modules:
|
|
||||||
await m.initialize(self)
|
|
||||||
await super().start()
|
|
||||||
self.scheduler.resume()
|
|
||||||
|
|
||||||
async def stop(self, force:bool=False):
|
|
||||||
self.scheduler.pause()
|
|
||||||
await super().stop(force=force)
|
|
||||||
for m in self.modules:
|
|
||||||
await m.cleanup(self)
|
|
||||||
await self.notifier.cleanup(self)
|
|
||||||
|
|
||||||
def add(self, module:LogicModule):
|
|
||||||
module.register(self)
|
|
||||||
self.modules.append(module)
|
|
||||||
|
|
||||||
def on_chat(self, msg_type:Union[str, MessageType] = None):
|
|
||||||
if isinstance(msg_type, str):
|
|
||||||
msg_type = MessageType(msg_type)
|
|
||||||
def wrapper(fun):
|
|
||||||
async def process_chat_packet(packet:PacketChatMessage):
|
|
||||||
msg = ChatEvent(packet.message)
|
|
||||||
if not msg_type or msg.type == msg_type:
|
|
||||||
return await fun(msg)
|
|
||||||
self.register(PacketChatMessage, process_chat_packet)
|
|
||||||
return fun
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def on_death(self):
|
|
||||||
def wrapper(fun):
|
|
||||||
return self.register(TreepuncherEvents.DIED, fun)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def on_joined_world(self):
|
|
||||||
def wrapper(fun):
|
|
||||||
return self.register(TreepuncherEvents.IN_GAME, fun)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
async def write(self, packet:Packet, wait:bool=False):
|
|
||||||
await self.dispatcher.write(packet, wait)
|
|
||||||
|
|
||||||
async def chat(self, message:str, whisper:str=None, wait:bool=False):
|
|
||||||
if whisper:
|
|
||||||
message = f"/w {whisper} {message}"
|
|
||||||
await self.dispatcher.write(
|
|
||||||
PacketChat(
|
|
||||||
self.dispatcher.proto,
|
|
||||||
message=message
|
|
||||||
),
|
|
||||||
wait=wait
|
|
||||||
)
|
|
||||||
|
|
||||||
async def set_slot(self, slot:int):
|
|
||||||
self.slot = slot
|
|
||||||
await self.dispatcher.write(PacketHeldItemSlot(self.dispatcher.proto, slotId=slot))
|
|
||||||
|
|
||||||
def _register_handlers(self):
|
|
||||||
@self.on_disconnected()
|
|
||||||
async def disconnected_cb():
|
|
||||||
self.in_game = False
|
|
||||||
|
|
||||||
@self.on_connected()
|
|
||||||
async def connected_cb():
|
|
||||||
self.tablist.clear()
|
|
||||||
|
|
||||||
@self.on_packet(PacketSetSlot)
|
|
||||||
async def on_set_slot(packet:PacketSetSlot):
|
|
||||||
if packet.windowId == 0: # player inventory
|
|
||||||
self.inventory[packet.slot] = packet.item
|
|
||||||
|
|
||||||
@self.on_packet(PacketHeldItemChange)
|
|
||||||
async def on_held_item_change(packet:PacketHeldItemChange):
|
|
||||||
self.slot = packet.slot
|
|
||||||
|
|
||||||
@self.on_packet(PacketRespawn)
|
|
||||||
async def on_player_respawning(packet:PacketRespawn):
|
|
||||||
self.gamemode = Gamemode(packet.gamemode)
|
|
||||||
self.dimension = Dimension(packet.dimension)
|
|
||||||
self.difficulty = Difficulty(packet.difficulty)
|
|
||||||
if self.difficulty != Difficulty.PEACEFUL \
|
|
||||||
and self.gamemode != Gamemode.SPECTATOR:
|
|
||||||
self.in_game = True
|
|
||||||
else:
|
|
||||||
self.in_game = False
|
|
||||||
self._logger.info(
|
|
||||||
"Reloading world: %s (%s) in %s",
|
|
||||||
self.dimension.name,
|
|
||||||
self.difficulty.name,
|
|
||||||
self.gamemode.name
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.on_packet(PacketLogin)
|
|
||||||
async def player_joining_cb(packet:PacketLogin):
|
|
||||||
self.gamemode = Gamemode(packet.gameMode)
|
|
||||||
self.dimension = Dimension(packet.dimension)
|
|
||||||
self.difficulty = Difficulty(packet.difficulty)
|
|
||||||
self.join_time = datetime.datetime.now()
|
|
||||||
if self.difficulty != Difficulty.PEACEFUL \
|
|
||||||
and self.gamemode != Gamemode.SPECTATOR:
|
|
||||||
self.in_game = True
|
|
||||||
else:
|
|
||||||
self.in_game = False
|
|
||||||
self._logger.info(
|
|
||||||
"Joined world: %s (%s) in %s",
|
|
||||||
self.dimension.name,
|
|
||||||
self.difficulty.name,
|
|
||||||
self.gamemode.name
|
|
||||||
)
|
|
||||||
self.run_callbacks(TreepuncherEvents.IN_GAME)
|
|
||||||
await self.write(
|
|
||||||
PacketSettings(
|
|
||||||
self.dispatcher.proto,
|
|
||||||
locale="en_US",
|
|
||||||
viewDistance=4,
|
|
||||||
chatFlags=0,
|
|
||||||
chatColors=True,
|
|
||||||
skinParts=0xF,
|
|
||||||
mainHand=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await self.write(PacketClientCommand(self.dispatcher.proto, actionId=0))
|
|
||||||
|
|
||||||
@self.on_packet(PacketPosition)
|
|
||||||
async def player_rubberband_cb(packet:PacketPosition):
|
|
||||||
self.position = BlockPos(packet.x, packet.y, packet.z)
|
|
||||||
self._logger.info(
|
|
||||||
"Position synchronized : (x:%.0f,y:%.0f,z:%.0f)",
|
|
||||||
self.position.x, self.position.y, self.position.z
|
|
||||||
)
|
|
||||||
await self.dispatcher.write(
|
|
||||||
PacketTeleportConfirm(
|
|
||||||
self.dispatcher.proto,
|
|
||||||
teleportId=packet.teleportId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.on_packet(PacketUpdateHealth)
|
|
||||||
async def player_hp_cb(packet:PacketUpdateHealth):
|
|
||||||
died = packet.health != self.hp and packet.health <= 0
|
|
||||||
self.hp = packet.health
|
|
||||||
self.food = packet.food + packet.foodSaturation
|
|
||||||
if died:
|
|
||||||
self.run_callbacks(TreepuncherEvents.DIED)
|
|
||||||
self._logger.info("Dead, respawning...")
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
await self.dispatcher.write(
|
|
||||||
PacketClientCommand(self.dispatcher.proto, actionId=0) # respawn
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.on_packet(PacketExperience)
|
|
||||||
async def player_xp_cb(packet:PacketExperience):
|
|
||||||
if packet.level != self.lvl:
|
|
||||||
self._logger.info("Level up : %d", packet.level)
|
|
||||||
self.xp = packet.experienceBar
|
|
||||||
self.lvl = packet.level
|
|
||||||
self.total_xp = packet.totalExperience
|
|
||||||
|
|
||||||
@self.on_packet(PacketPlayerInfo)
|
|
||||||
async def tablist_update(packet:PacketPlayerInfo):
|
|
||||||
for record in packet.data:
|
|
||||||
uid = record['UUID']
|
|
||||||
if packet.action != 0 and uid not in self.tablist:
|
|
||||||
continue # TODO this happens kinda often but doesn't seem to be an issue?
|
|
||||||
if packet.action == 0:
|
|
||||||
self.tablist[uid] = record
|
|
||||||
self.tablist[uid]['joinTime'] = datetime.datetime.now()
|
|
||||||
elif packet.action == 1:
|
|
||||||
self.tablist[uid]['gamemode'] = record['gamemode']
|
|
||||||
elif packet.action == 2:
|
|
||||||
self.tablist[uid]['ping'] = record['ping']
|
|
||||||
elif packet.action == 3:
|
|
||||||
self.tablist[uid]['displayName'] = record['displayName']
|
|
||||||
elif packet.action == 4:
|
|
||||||
self.tablist.pop(uid, None)
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue