Compare commits

...

145 commits
main ... dev

Author SHA1 Message Date
aaf36bdf5f docs: improved installation instructions 2024-02-20 16:23:49 +01:00
4c333c7852 docs: fix aiocraft repo link 2024-02-20 16:03:54 +01:00
John
5be33deb81 aiocraft url updated pt2 2024-02-10 12:07:06 +01:00
John
9ec38961f5 Updated aiocraft url as per url renaming 2024-02-10 11:58:33 +01:00
8101a255ad
docs: explain about addons and sections 2023-11-20 19:27:37 +01:00
783a619bae
docs: wording, consistency 2023-11-20 19:23:11 +01:00
eb723f76f5
docs: improved README.md a ton 2023-11-20 18:55:14 +01:00
71d5f0363f
fix: lock aiocraft dep version 2023-11-20 18:55:01 +01:00
39fea517cb
fix: pass login code from CLI 2023-11-20 18:54:50 +01:00
f9c97c0c34
chore: updated aiocraft 2023-11-20 17:52:49 +01:00
bd97b891f4
fix: handle SIGTERM too 2023-11-20 17:51:40 +01:00
5586d1fc9c
fix: still default to not processing the world 2023-11-02 06:23:04 +01:00
ff39260fa4
fix: wrong packet, damn prismarinejs... 2023-11-02 06:21:36 +01:00
f0a4fe9872
fix: oops initialize the field 2023-11-02 05:41:45 +01:00
caeb5215cc
Merge branch 'dev' of fantabos.co:treepuncher into dev 2023-11-02 05:39:51 +01:00
56065d62f8
fix: disabled multi block change, refactored 2023-11-02 05:32:50 +01:00
989731574a
fix: typing, create log folder if missing 2023-11-02 05:32:21 +01:00
f97bf06b5a chore: updated build system 2023-06-03 13:43:02 +02:00
0ed7f26c7d chore: version bump 2023-06-02 15:36:22 +02:00
0b4847fb9c fix: why was I awaiting run_callbacks 2023-06-02 15:34:27 +02:00
99a4a78dcb fix: wrong field 2023-06-02 15:30:29 +02:00
f920bbc838 feat: split world and position, added BlockUpdateEvent 2023-03-17 11:07:13 +01:00
e6a1ecc5c4 fix: correctly process 1.16 dimension and difficulty 2023-03-17 10:33:38 +01:00
82cd2fbf1d
fix: set _last_steer_vehicle 2023-03-16 16:32:07 +01:00
8655c5ebde
fix: there isn't a scheduler yet when registering 2023-03-16 16:31:03 +01:00
ef8ffac391 feat: send SteerVehicle packets every 5s if riding
This is not the best way to do it. Mostly an ad-hoc fix for a tunnel
bore addon...
2023-03-16 14:13:36 +01:00
bcdb5a6694
feat: also follow vehicle movement 2023-03-14 21:05:19 +01:00
36a2fece2f
fix: temporary cheap fix for weird stuff? 2023-02-16 22:09:16 +01:00
58ab0e0378
fix: don't lock proto version... 2023-02-16 22:07:52 +01:00
f5256da6bf
fix: must be None 2023-02-16 18:21:26 +01:00
98c82bcbcf
fix: server is not positional anymore 2023-02-16 18:11:59 +01:00
e20f7a967f
feat: allow passing session/auth server overrides 2023-02-16 18:10:06 +01:00
3b7aa42e0b
fix: delete from namespaced storage when putting... 2022-08-25 20:50:43 +02:00
aa0e804965
fix: return None for missing keys 2022-08-25 20:15:04 +02:00
b750aa140f
fix: i actually sorta finished it and added a migration script 2022-08-25 20:03:33 +02:00
ba721a4b30
fix: this was wip, im not ready to migrate yet 2022-08-25 19:52:52 +02:00
b14ca9d862
feat: automatically attempt reauth when timed out
sometimes MS times out our refresh request, making clients disconnect.
Implemented a retry loop, which will attempt to authenticate up to 5
times (configurable) waiting 60 seconds in between (configurable).
Only timeouts will be handled, any other exception will make it crash
immediately.
2022-08-25 19:42:53 +02:00
1499cab317
feat: added namespaced AddonStorage helper
Co-authored-by: f-tlm <f-tlm@users.noreply.github.com>
2022-08-13 14:14:57 +02:00
f5481ace65
feat: Added PlayerJoin/PlayerLeave events
Co-authored-by: f-tlm <f-tlm@users.noreply.github.com>
2022-08-13 14:13:17 +02:00
8f3f8fd69b
fix: cheap way to get the auth url 2022-07-14 13:50:57 +02:00
5465837478
fix: correctly reset window state 2022-07-06 12:42:44 +02:00
bcf293a8ad
style: moved settings into globals 2022-07-06 12:42:29 +02:00
2ad89a61d2
chore: show which callback threw an exc 2022-07-03 19:10:36 +02:00
425a7c7f71
fix: setBlock was messing up the world... 2022-07-03 17:24:31 +02:00
f04d873d21
fix: I actually read wiki.vg 2022-07-03 17:13:25 +02:00
acdb60cd2f
fix: maybe I'm supposed to close containers when I receive windowId < 0 ? 2022-07-03 17:09:05 +02:00
c2ea55c699
fix: oversize container slots because minecraft is dumb 2022-07-03 16:54:40 +02:00
aa6f2b03bb
fix: add 36 slots for inventory 2022-07-03 02:02:45 +02:00
f6ee4b0d79
fix: slots + 1 2022-07-03 01:57:36 +02:00
312f132c50
fix: stop notifier 2022-07-03 01:19:00 +02:00
5abfb63bd3
feat: log module cleanup in debug 2022-07-03 01:15:50 +02:00
0540011bb6
fix: jank way to store block entities 2022-07-03 00:33:08 +02:00
d8c62e57d2
fix: use window_id 0 for "no container" 2022-07-03 00:27:29 +02:00
363eeea3aa
fix: initialize attributes 2022-07-03 00:16:00 +02:00
011531b1a4
fix: damn auto imports 2022-07-03 00:12:36 +02:00
48590d56bd
feat: process world (default off) 2022-07-03 00:06:15 +02:00
a563984e48
feat: parse Optional types in config object 2022-07-03 00:02:39 +02:00
61811f542b
feat: track container windows 2022-07-03 00:02:00 +02:00
d54fb380e9
fix: set total_xp in constructor 2022-06-18 16:13:26 +02:00
664cb64613
change MRO 2022-05-23 03:01:26 +02:00
4527b866bf
also show str(exception) 2022-05-23 02:41:01 +02:00
76c84006ad
put session files under data folder 2022-05-23 02:36:44 +02:00
847bde3d79
resolve srv default true 2022-05-23 02:27:33 +02:00
6364f52d0b
Don't str(None) 2022-05-23 02:22:19 +02:00
d2a13360a8
Don't mix falsy value with no value 2022-05-23 02:14:17 +02:00
4c6d906d3a
accept and pass args and kwargs in trait constructors 2022-05-23 02:01:28 +02:00
79886a2c64
small fixes 2022-05-23 01:45:04 +02:00
5f2beac41b
Moved config into scaffold, tweaks to init options
Changes for SRV resolution, get online_mode and legacy also from config
2022-05-23 01:11:06 +02:00
3067032f1b
use bare SectionProxy, remove UDP
bare SectionProxy won't throw KeyError when there's no [Treepuncher]
section in config and we're trying to access fields in it: we want
default value to be returned so it can fallback accordingly
2022-05-10 01:30:39 +02:00
f95f594967
added use_udp option 2022-05-09 23:53:50 +02:00
fdfe71b481
log a ton start/stop sequence
This is temporary: sometimes it gets stuck into stop() (I think?) so I'm
adding some debug prints everywhere so every instance can be used to
spot this issue
2022-05-06 10:35:24 +02:00
bbe668bcec
track HP changes 2022-05-05 10:30:57 +02:00
3548603578
track player abilities 2022-05-05 00:26:45 +02:00
b8477c3d22
use explicit deserialize 2022-05-05 00:19:40 +02:00
2885c3c270
changes for player type, install return instance, fixes 2022-05-04 23:35:46 +02:00
98d3967dfe
more limited jank fix 2022-04-29 00:45:46 +02:00
c2a9726ec6
install providers first 2022-04-28 13:46:14 +02:00
c29a03aeda
jank fix for 'Treepuncher' type hint 2022-04-28 13:44:17 +02:00
757be795c3
fix: lookup types with globalns 2022-04-28 13:18:18 +02:00
8b9848cdfb
useless commit spree: forgot I removed this 2022-04-28 13:00:52 +02:00
1ecb1c44e8
ops need to be typing strings 2022-04-28 12:59:52 +02:00
8503a36df4
ops xor was wrong 2022-04-28 12:57:40 +02:00
7c4c523562
print timestamp on stdout too 2022-04-28 12:54:23 +02:00
cfd5dd079a
added notifier/provider structure, moved addon out
now treepuncher has 1 notifier, which holds many providers for various
services. Each provider is an Addon, but not the Notifier itself.
Moved Addon and Notifier out of Treepuncher, and added type hints without
dependancy cycle with TYPE_CHECKING control
2022-04-28 01:57:19 +02:00
1307869d9c
Allow to enable logging of ignored packets 2022-04-27 13:30:19 +02:00
aeea271547
Merge branch 'microsoft' of github.com:alemidev/treepuncher into microsoft 2022-04-26 23:24:13 +02:00
61cfd72d1f
Assure notifier is initialized first 2022-04-26 23:23:46 +02:00
a3aacd6291
catch addon exc with bare traceback 2022-04-26 23:23:33 +02:00
Francesco Tolomei
b92791ce04
windows fix 2022-04-26 02:31:42 +02:00
91afbbc28a
fallback to string 2022-04-25 15:36:05 +02:00
f0c34376fa
keep and expose single loop 2022-04-25 04:43:15 +02:00
4e4bb50e4a
add sub logger to each addon 2022-04-25 04:43:00 +02:00
b73a352484
fix double logger 2022-04-24 03:21:45 +02:00
e52b4d5a37
hopefully clearer exception handling and _work fn 2022-04-23 22:09:53 +02:00
261d18f854
try/catch importing modules for help 2022-04-23 22:06:09 +02:00
086e59a0ce
sort union types so str comes last 2022-04-23 15:55:19 +02:00
977ce7a9de
reordered stop sequence 2022-04-23 15:22:04 +02:00
d5d1f8c036
added force_proto config, catch connection errors in ping 2022-04-23 15:18:11 +02:00
15c2fc09dc
bump version 2022-04-23 14:34:05 +02:00
5a652f1710
try to parse Union hints 2022-04-23 14:33:30 +02:00
6f53778fc9
typo :( 2022-04-20 00:20:24 +02:00
eefa1bf211
separated auth storage from sys storage 2022-04-20 00:02:24 +02:00
6a39e61e03
give self to default notifier 2022-04-19 12:06:21 +02:00
c6ff6a6bf7
oof fix 2022-04-19 11:59:55 +02:00
5527d50ff3
hopeful fix for self-stop 2022-04-19 11:54:13 +02:00
c87dbd63e2
set proto only if it was given 2022-04-19 11:46:35 +02:00
923dfa3532
readded ability to specify plugins via CLI 2022-04-19 11:31:37 +02:00
cdc5e9301f
CLI improvements 2022-04-19 04:05:38 +02:00
fa4f0dc58a
was importing wrong packet? 2022-04-19 03:54:27 +02:00
c0f698ac7d
oops run the events 2022-04-19 03:51:02 +02:00
4da1a9f834
load each addon only once but for real 2022-04-19 03:36:30 +02:00
6d64973c54
join_game_event holds args inside itself 2022-04-19 03:36:20 +02:00
db01f5d5b7
install notifiers first 2022-04-19 03:22:53 +02:00
148c809628
don't initialize notifier multiple times 2022-04-19 03:14:34 +02:00
6813181800
pass args kwargs in notifier 2022-04-19 02:36:21 +02:00
49fe4f185f
all the opposite 2022-04-19 02:26:40 +02:00
631ff0bc2c
fixed imports 2022-04-19 02:17:35 +02:00
3b9880ad6b
circular import 2022-04-19 02:14:46 +02:00
7b7c46e184
notifier is an addon and is taken from installed addons 2022-04-19 01:56:20 +02:00
7b5958dde3
various improvements
add print-token flag, put logs in log folder, load previous session after
aiocraft client setup, don't sleep if stopping
2022-04-19 01:17:37 +02:00
ab2170e669
fancier missing required parameter 2022-04-19 00:43:39 +02:00
9d7badba5d
handle auth exception 2022-04-19 00:23:56 +02:00
280d97afc8
didn't work but I found get_origin! 2022-04-19 00:19:13 +02:00
e18c725a6b
improved config parsing
now should try to respect type hint arguments (such as list[int]) and
work with typing stuff (List, Set)
2022-04-19 00:10:43 +02:00
28f84ca095
default to only configured addons 2022-04-18 21:45:02 +02:00
aebe8c1965
also in main 2022-04-18 20:46:26 +02:00
ab9d45c244
various fixes 2022-04-18 20:44:20 +02:00
814e489142
pass --mojang ... 2022-04-18 20:43:57 +02:00
a29cd737e8
fix: prepare dispatcher properly 2022-04-18 20:25:52 +02:00
d53272e411
some typing magic for retro compatibility 2022-04-18 20:06:54 +02:00
016e4170df
allow to use legacy token in config 2022-04-18 20:06:42 +02:00
af4cf7db90
reworked initialization
tldr now treepuncher handles initializing everything
2022-04-18 19:35:49 +02:00
c9f65f56aa
fixes for logger attribute 2022-04-18 19:35:11 +02:00
Francesco Tolomei
e2459f7982
WIP: authentication rework 2022-04-07 00:05:14 +02:00
f04cf02b4e
added scaffold 2022-04-06 21:31:59 +02:00
fafb19a7a2
added some events? 2022-04-06 21:30:54 +02:00
a5169f40cc
added traits 2022-04-06 21:30:15 +02:00
88bfadd8cd
brought features out of aiocraft, added game managers 2022-03-08 01:38:14 +01:00
b41f2f6074
small improvement 2022-02-20 14:32:52 +01:00
790f5cbb1f added .editorconfig 2022-02-20 13:27:52 +01:00
39dcfc4245 packaged new-style 2022-02-20 13:12:50 +01:00
d12b4b4f88
Various fixes, implemented credentials storage and addons with config vars 2022-02-16 04:07:11 +01:00
d5934da832
improvements, tweaks to addons system 2022-02-16 02:46:47 +01:00
78b97a42a6
ocd 2022-02-15 17:08:41 +01:00
4b1b508be9
restructured treepuncher, reworked addons system
Moved most of Treepuncher logic into 'game/*' files, making it more
modular. Changed a little bit events (but will need much more work).
Added SQLite storage and ini config file, for persistance. Moved the
launcher inside __main__, making it a launchable python module.

There still is some confusion between Addons and game logic components.
2022-02-15 16:53:53 +01:00
43 changed files with 1598 additions and 442 deletions

10
.editorconfig Normal file
View 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
View file

@ -127,3 +127,6 @@ dmypy.json
# Pyre type checker
.pyre/
# Auto generated version file
src/treepuncher/__version__.py

111
README.md
View file

@ -1,2 +1,111 @@
# 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
View 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"

View file

@ -1,2 +0,0 @@
apscheduler
aiocraft

View 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])

View file

@ -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',
],
)

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

View 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

View file

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

View 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

View file

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

View 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

View file

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

View 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

View 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

View file

@ -0,0 +1,8 @@
from .base import BaseEvent
class ConnectedEvent(BaseEvent):
pass
class DisconnectedEvent(BaseEvent):
pass

View 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

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

View 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,
)
)

View 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

View 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

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

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

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

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

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

View file

@ -0,0 +1,3 @@
from .callbacks import CallbacksHolder
from .runnable import Runnable

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

View 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")

View 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")

View file

@ -1 +0,0 @@
from .treepuncher import Treepuncher

View file

@ -1 +0,0 @@
from .chat import ChatEvent

View file

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

View file

@ -1,81 +0,0 @@
from ..treepuncher import Treepuncher, TreepuncherEvents
from .module import LogicModule
from aiocraft.mc.proto.play.clientbound import (
PacketRespawn, PacketLogin, PacketPosition, PacketUpdateHealth, PacketExperience,
PacketAbilities, PacketChat as PacketChatMessage
)
from aiocraft.mc.proto.play.serverbound import PacketTeleportConfirm, PacketClientCommand, PacketChat
from aiocraft.mc.definitions import Difficulty, Dimension, Gamemode, Position
class CoreLogic(LogicModule):
def register(self, client:Treepuncher):
@client.on_disconnected()
async def on_disconnected():
client.in_game = False
@client.on_packet(PacketRespawn)
async def on_player_respawning(packet:PacketRespawn):
client.gamemode = Gamemode(packet.gamemode)
client.dimension = Dimension(packet.dimension)
client.difficulty = Difficulty(packet.difficulty)
if client.difficulty != Difficulty.PEACEFUL \
and client.gamemode != Gamemode.SPECTATOR:
client.in_game = True
else:
client.in_game = False
client._logger.info(
"Reloading world: %s (%s) in %s",
client.dimension.name,
client.difficulty.name,
client.gamemode.name
)
@client.on_packet(PacketLogin)
async def player_joining_cb(packet:PacketLogin):
client.gamemode = Gamemode(packet.gameMode)
client.dimension = Dimension(packet.dimension)
client.difficulty = Difficulty(packet.difficulty)
if client.difficulty != Difficulty.PEACEFUL \
and client.gamemode != Gamemode.SPECTATOR:
client.in_game = True
else:
client.in_game = False
client._logger.info(
"Joined world: %s (%s) in %s",
client.dimension.name,
client.difficulty.name,
client.gamemode.name
)
client.run_callbacks(TreepuncherEvents.IN_GAME)
@client.on_packet(PacketPosition)
async def player_rubberband_cb(packet:PacketPosition):
client._logger.info("Position synchronized")
client.position = Position(packet.x, packet.y, packet.z)
await client.dispatcher.write(
PacketTeleportConfirm(
client.dispatcher.proto,
teleportId=packet.teleportId
)
)
@client.on_packet(PacketUpdateHealth)
async def player_hp_cb(packet:PacketUpdateHealth):
if packet.health != client.hp and packet.health <= 0:
client._logger.info("Dead, respawning...")
await client.dispatcher.write(
PacketClientCommand(client.dispatcher.proto, actionId=0) # respawn
)
client.run_callbacks(TreepuncherEvents.DIED)
client.hp = packet.health
client.food = packet.food
@client.on_packet(PacketExperience)
async def player_xp_cb(packet:PacketExperience):
if packet.level != client.lvl:
client._logger.info("Level up : %d", packet.level)
client.xp = packet.experienceBar
client.lvl = packet.level
client.total_xp = packet.totalExperience

View file

@ -1,11 +0,0 @@
class LogicModule:
def register(self, client:'Treepuncher') -> None:
pass # override to register callbacks on client
async def initialize(self, client:'Treepuncher') -> None:
pass # override to register stuff on client start
async def cleanup(self, client:'Treepuncher') -> None:
pass # override to register stuff on client stop

View file

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

View file

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