diff --git a/.github/workflows/minetester.yml b/.github/workflows/minetester.yml new file mode 100644 index 0000000000000..514bf8b01c380 --- /dev/null +++ b/.github/workflows/minetester.yml @@ -0,0 +1,51 @@ +name: Build and test Minetester + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install Linux dependencies + run: make linux_deps + - name: Install build dependencies + run: make python_build_deps + - name: Init submodules + run: make repos + - name: Build SDL2 + run: make sdl2 + - name: Build zmqpp + run: make zmqpp + - name: Create Protobuf files + run: make proto + - name: Build irrlicht + run: bash util/minetester/build_irrlicht.sh + - name: Build minetest binary + run: make minetest + - name: Build minetester wheel + run: make minetester + - name: Install minetester along with dependencies + run: | + python -m pip install --upgrade pip + make install + - name: Run pre-commit hooks + run: | + pip install pre-commit + pre-commit install + pre-commit run --all-files diff --git a/.gitmodules b/.gitmodules index 60fe8cd6917e6..ee38106856a2f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,14 +1,14 @@ -[submodule "lib/irrlichtmt"] - path = lib/irrlichtmt - url = https://github.com/EleutherAI/irrlicht.git - branch = headless-rendering - -[submodule "lib/zmqpp"] - path = lib/zmqpp - url = https://github.com/zeromq/zmqpp.git [submodule "games/minetest_game"] path = games/minetest_game url = https://github.com/minetest/minetest_game.git [submodule "lib/SDL"] path = lib/SDL url = https://github.com/libsdl-org/SDL.git + branch = SDL2 +[submodule "lib/zmqpp"] + path = lib/zmqpp + url = https://github.com/zeromq/zmqpp.git +[submodule "lib/irrlicht"] + path = lib/irrlichtmt + url = https://github.com/EleutherAI/irrlicht.git + branch = headless-rendering diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000..0e1d694a90c17 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +# Linting +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-ast + files: ^minetester/.*$ + - id: trailing-whitespace + files: ^minetester/.*$ + - id: end-of-file-fixer + exclude_types: [jupyter] + files: ^minetester/.*$ + - id: check-added-large-files + files: ^minetester/.*$ +- repo: https://github.com/psf/black + rev: 22.6.0 + hooks: + - id: black + files: ^minetester/.*$ + - id: black-jupyter + files: ^minetester/.*$ +- repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black", "--filter-files"] + files: ^minetester/.*$ +# Python static analysis +- repo: https://github.com/pycqa/flake8 + rev: '5.0.4' + hooks: + - id: flake8 + additional_dependencies: + - darglint~=1.8.1 + - flake8-blind-except==0.2.1 + - flake8-builtins~=1.5.3 + - flake8-commas~=2.1.0 + - flake8-debugger~=4.1.2 + - flake8-docstrings~=1.6.0 + files: ^minetester/.*$ \ No newline at end of file diff --git a/Makefile b/Makefile index fba60893a26a2..11cdb142c0ccf 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ -.PHONY: all deps repos sdl2 package zmqpp minetester minetest install demo proto clean +.PHONY: all deps repos sdl2 package zmqpp irrlicht minetester minetest install demo proto clean MINETESTER_VERSION := 0.0.1 SDL2_CMAKE_FILE := lib/SDL/build/lib/cmake/SDL2/sdl2-config.cmake ZMQPP_LIB_FILE := lib/zmqpp/build/max-g++/libzmqpp.a +IRRLICHTMT_LIB_FILE := lib/irrlichtmt/lib/Linux/libIrrlichtMt.a MINETEST_BINARY := bin/minetest DEBUG_BINARY := bin/debug MINETESTER_WHEEL := build/package/wheel/minetester-$(MINETESTER_VERSION)-py3-none-manylinux_2_35_x86_64.whl @@ -38,6 +39,10 @@ $(ZMQPP_LIB_FILE): zmqpp: $(ZMQPP_LIB_FILE) +$(IRRLICHTMT_LIB_FILE): + util/minetester/build_irrlicht.sh + +irrlicht: $(IRRLICHTMT_LIB_FILE) $(MINETEST_BINARY): #build minetest binary diff --git a/README.md b/README.md index 14436da6413d3..b547f3df56082 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,18 @@ Minetester ========== Minetester is the Python package that exposes Minetest environments via the `gym(nasium)` interface. After [building the minetest executable](https://github.com/EleutherAI/minetest/blob/develop/build_instructions.txt) you can install it with: -``` +``` bash pip install -e . ``` To verify the installation run -``` +``` bash python -m minetester.scripts.test_loop ``` +To build the documentation in `docs-minetester` please run +``` bash +pip install -e .[docs] +cd docs-minetester && make livehtml +``` Quick Build Instructions for Linux ================================== @@ -45,7 +50,7 @@ make clean #clean up build artifacts Additionally the makefile supports a utility to clean only the minetester install -``` +```bash make clean_minetester #remove existing minetester install make minetester #build minetester python library make install #install python library into local environment along with nessesary dependencies diff --git a/build_instructions.txt b/build_instructions.txt index ec95d41369ddb..b3db4471c39a0 100644 --- a/build_instructions.txt +++ b/build_instructions.txt @@ -1,46 +1,20 @@ -1. Install prereqs - 1. sudo apt-get install xvfb g++ make libzmq3-dev libtool pkg-config build-essential autoconf automake libc6-dev cmake libpng-dev libjpeg-dev libxi-dev libgl1-mesa-dev libsqlite3-dev libogg-dev libvorbis-dev libopenal-dev libcurl4-gnutls-dev libfreetype6-dev zlib1g-dev libgmp-dev libjsoncpp-dev libzstd-dev libluajit-5.1-dev protobuf-compiler - -2. Build SDL2 - 1. clone the SDL2 repo https://github.com/libsdl-org/SDL - 2. checkout release-2.26.2 https://github.com/libsdl-org/SDL/tree/release-2.26.2 - 3. create a build directory inside the SDL repo - 4. cd into it and run ../configure --prefix=/path/to/SDL/dir/build && make && make install - -3. Build zmqpp - 1. clone https://github.com/zeromq/zmqpp - 2. checkout the develop branch - 3. run make - -4. Clone EAI alignment minetest repos - 1. clone https://github.com/EleutherAI/minetest - 2. checkout the develop branch - 3. clone https://github.com/EleutherAI/irrlicht - 4. checkout headless-renderer - -5. Clone minetest game spec - 1. clone https://github.com/minetest/minetest_game - 2. checkout master branch - -5. Establish symlinks - 1. cd into minetest/lib - 2. rm -r zmqpp irrlichtmt - 3. ln -s ../../zmqpp/ zmqpp - 4. ln -s ../../irrlicht/ irrlichtmt - 5. cd into minetest/games - 6. ln -s ../../minetest_game/ minetest_game - -6. Build minetest - 1. cd into minetest - 2. either run - cmake . -DRUN_IN_PLACE=TRUE -DBUILD_HEADLESS=1 -DSDL2_DIR=/SDL/build/lib/cmake/SDL2/ - or - cmake . -DRUN_IN_PLACE=TRUE -DBUILD_HEADLESS=0 -DSDL2_DIR= - 3. run make -j$(nproc) - -7. setup python - 1. create and activate a new python conda env or venv (tested with python3.9) - 2. pip install gym matplotlib protobuf==3.20.1 psutil zmq -e ./minetest - 3. cd into the scripts directory and run compile_proto.sh - 4. run python -m minetester.scripts.test_loop - +# 1. Install debian dependencies, equivalent commands are nessesary for other distros +make deb_deps +# 2. Install build dependencies into the local python environment (we reccomend using a venv) +make python_build_deps +# 3. Init submodules +make repos +# 4. Build sdl2 +make sdl2 +# 5. Build zmqpp +make zmqpp +# 6. Create c++ and python protobuf files +make proto +# 7. Build minetest binary +make minetest +# 8. Build minetester python library +make minetester +# 9. Install minetester into local environment along with necessary dependencies +make install +# 10. Run the demo script +make demo \ No newline at end of file diff --git a/clientmods/mods.conf b/clientmods/mods.conf index 0b96651b71bd5..f517fccc8dfab 100644 --- a/clientmods/mods.conf +++ b/clientmods/mods.conf @@ -1,7 +1,7 @@ -load_mod_random_v0 = true load_mod_rewards = true load_mod_treechop_v1 = false load_mod_treechop_shaped_v0 = false load_mod_random_v1 = false -load_mod_preview = false load_mod_treechop_v0 = false +load_mod_random_v0 = false +load_mod_preview = false diff --git a/docs-minetester/Makefile b/docs-minetester/Makefile new file mode 100644 index 0000000000000..bf8c567632456 --- /dev/null +++ b/docs-minetester/Makefile @@ -0,0 +1,23 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +livehtml: + sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) --open-browser \ No newline at end of file diff --git a/docs-minetester/build.sh b/docs-minetester/build.sh new file mode 100755 index 0000000000000..19dad8b2131b3 --- /dev/null +++ b/docs-minetester/build.sh @@ -0,0 +1 @@ +sphinx-apidoc -o ./source ../minetester ../minetester/proto \ No newline at end of file diff --git a/docs-minetester/make.bat b/docs-minetester/make.bat new file mode 100644 index 0000000000000..747ffb7b30336 --- /dev/null +++ b/docs-minetester/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs-minetester/source/.gitignore b/docs-minetester/source/.gitignore new file mode 100644 index 0000000000000..e64636897f362 --- /dev/null +++ b/docs-minetester/source/.gitignore @@ -0,0 +1 @@ +_api \ No newline at end of file diff --git a/docs-minetester/source/_templates/module.rst b/docs-minetester/source/_templates/module.rst new file mode 100644 index 0000000000000..7db58b1ed84be --- /dev/null +++ b/docs-minetester/source/_templates/module.rst @@ -0,0 +1,30 @@ +{{ fullname | escape | underline }} + +Description +----------- + +.. automodule:: {{ fullname | escape }} + +{% if classes %} +Classes +------- +.. autosummary: + :toctree: _autosummary + + {% for class in classes %} + {{ class }} + {% endfor %} + +{% endif %} + +{% if functions %} +Functions +--------- +.. autosummary: + :toctree: _autosummary + + {% for function in functions %} + {{ function }} + {% endfor %} + +{% endif %} \ No newline at end of file diff --git a/docs-minetester/source/advanced/client_api.rst b/docs-minetester/source/advanced/client_api.rst new file mode 100644 index 0000000000000..b51fb9cc9b996 --- /dev/null +++ b/docs-minetester/source/advanced/client_api.rst @@ -0,0 +1,37 @@ +Client API +========== + +The API provided for controlling and reading out the Minetest client is based on two libraries: + +- `Protocol Buffers `_ +- `ZeroMQ `_ + +Protobuf objects are used for serializing and deserializing the messages, that are being sent using ZeroMQ. + +Protobuf objects +---------------- + +The objects are defined in `/proto/objects.proto `_. +The Minetest client receives ``Action`` messages and sends out ``Observation`` messages. + +By default the Protobuf objects are compiled for C++ and Python using the +`/scripts/compile_proto.sh `_ script. +This can be easily adjusted to compile for other languages. + +ZeroMQ message patterns +----------------------- + +There are two differnt ZeroMQ messaging patterns in use depending on the type of the client: + +- dumb Minetest client <-> controller client: ``REQ/REP`` + + In each step the dumb client sends a blocking request containing an observation to the controller + which awaits the request and replies with an action. +- recording Minetest client <-> data gathering client : ``PUB/SUB`` + + In each step the recording client publishes an observation and the data gathering client + subscribes to the topic in order to read out the observations. + See `this script `_ + for an example of how to implement a data gathering client. + +In both cases the ZMQ socket address is passed to the Minetest client via the ``--client-address`` command line argument. diff --git a/docs-minetester/source/community/CODE_OF_CONDUCT.md b/docs-minetester/source/community/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000..efdecf5e89f5f --- /dev/null +++ b/docs-minetester/source/community/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +contact@eleuther.ai. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/docs-minetester/source/community/contributing.rst b/docs-minetester/source/community/contributing.rst new file mode 100644 index 0000000000000..072382fb5c44e --- /dev/null +++ b/docs-minetester/source/community/contributing.rst @@ -0,0 +1,53 @@ +Contributions guide +=================== + +Thank you for your interest in contributing to Minetester! This guide is meant to provide potential contributors with a clear overview +of how they can participate in the project, adhere to the community standards, and enhance the codebase. +All contributions, no matter how minor, are greatly appreciated. +All contributors are expected to adhere to the project's `Code of Conduct `_. + +Getting Started +--------------- + +- `Fork the repository on GitHub `_. + +- Clone the repository: + +.. code-block:: bash + + git clone https://github.com/your_username/minetest.git + +- Follow the `build instructions <../advanced/building_source.html>`_ to build from your fork's source code. +- In addition, you should install development tools and pre-commit: + +.. code-block:: bash + + pip install -e .[dev] + pre-commit install + +Making a Contribution +--------------------- + +- Look through the `GitHub Issues `_ to find one you are interested in addressing. You're also free to create your own issue if you identify a bug or enhancement that hasn't been raised yet. + +- Create a new branch with a somewhat informative name of what you are doing. + +.. code-block:: bash + + git checkout -b your_branch_name + +- Develop your bug fix or feature and write tests if possible. + +- Commit your changes: + +.. code-block:: bash + + git commit -m "Brief description of changes" + +- Push your changes to your fork. + +.. code-block:: bash + + git push origin your_branch_name + +- Open a pull request in the original repository. Please fill in the pull request template and reference the issue you're addressing. Feel free to request a review from one of the maintainers. diff --git a/docs-minetester/source/community/support.rst b/docs-minetester/source/community/support.rst new file mode 100644 index 0000000000000..5e7d9ebc5246c --- /dev/null +++ b/docs-minetester/source/community/support.rst @@ -0,0 +1,5 @@ +Support +======= + +- `Discord Channel `_ +- `GitHub Issues `_ \ No newline at end of file diff --git a/docs-minetester/source/conf.py b/docs-minetester/source/conf.py new file mode 100644 index 0000000000000..117f6ae884ddc --- /dev/null +++ b/docs-minetester/source/conf.py @@ -0,0 +1,55 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Minetester' +copyright = '2023, EleutherAI' +author = 'EleutherAI' +release = '0.0.1' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +templates_path = ['_templates'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +import os +import sys +sys.path.insert(0, os.path.abspath('../minetester')) +# ... +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx_autodoc_typehints', + 'sphinx.ext.napoleon', + 'sphinx_rtd_theme', + 'sphinx.ext.viewcode', + 'sphinx_copybutton', + 'sphinx.ext.autosummary', + 'myst_parser', +] +# ... +napoleon_google_docstring = True +napoleon_numpy_docstring = False +autosummary_generate = True +autodoc_default_options = { + "members": True, + "undoc-members": True, + "special-members": "__init__", + "show-inheritance": True, +} +source_suffix = { + ".rst": "restructuredtext", + ".txt": "markdown", + ".md": "markdown", +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'sphinx_rtd_theme' +html_title = "Minetester" +html_static_path = ['_static'] \ No newline at end of file diff --git a/docs-minetester/source/index.rst b/docs-minetester/source/index.rst new file mode 100644 index 0000000000000..95009ecffacc0 --- /dev/null +++ b/docs-minetester/source/index.rst @@ -0,0 +1,54 @@ +.. Minetester documentation master file, created by + sphinx-quickstart on Wed Jul 5 21:35:27 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +====================================== +Welcome to Minetester's documentation! +====================================== + +.. toctree:: + :maxdepth: 2 + :caption: Quickstart + + quickstart/installation + quickstart/what_is_minetester + quickstart/hello_minetest + +.. _tutorials: +.. toctree:: + :maxdepth: 1 + :caption: Tutorials + + tutorials/headless_mode + tutorials/synchronization + tutorials/create_task + +.. toctree:: + :maxdepth: 1 + :caption: Advanced Usage + + advanced/client_api + +.. toctree:: + :maxdepth: 1 + :caption: Community + + community/support + community/contributing + +Package Reference +~~~~~~~~~~~~~~~~~ +.. autosummary:: + :toctree: _api + :caption: Package Reference + :recursive: + :template: autosummary/module.rst + + minetester + +Index +===== + +* :ref:`genindex` +* :ref:`modindex` diff --git a/docs-minetester/source/quickstart/hello_minetest.rst b/docs-minetester/source/quickstart/hello_minetest.rst new file mode 100644 index 0000000000000..299009f8032a6 --- /dev/null +++ b/docs-minetester/source/quickstart/hello_minetest.rst @@ -0,0 +1,112 @@ +Hello, Minetester! +================== + +To get started with Minetester you should first know your way around the directory structure. +The most important directories are these: + +.. code-block:: yaml + + minetest/ + ├── bin # contains minetest executable + ├── clientmods # contains client-side mods + ├── doc # contains documentation + ├── docs-minetester # contains Minetester documentation + ├── games # contains games (vanilla Minetest by default) + ├── log # contains log files + ├── mods # contains server-side mods + ├── minetester # contains Minetester Python module + ├── scripts # contains utility scripts + └── src # contains C++ source code + + +Executing Minetest +------------------ + +You can jump directly into a single player game (skipping the main menu) by running + +.. code-block:: bash + + ./bin/minetest --go + +Internally, this command starts a server and a client on your computer and connects them to each other. + +There are many more command line options available. You can find them by running + +.. code-block:: bash + + ./bin/minetest --help + +For example, you can start a server and a client separately by running these commands in different terminals: + +.. code-block:: bash + + ./bin/minetest --server + ./bin/minetest --address 0.0.0.0 --port 30000 --go --name Bob + +A powerful way to modify Minetest is to pass a configuration file (see `minetest.conf.example `_ for available options): + +.. code-block:: bash + + ./bin/minetest --config minetest.conf + +Minetester command line options +------------------------------- + +.. list-table:: + :widths: 1 2 + :header-rows: 1 + + * - Minetester CLI Option + - Description + * - ``--dumb`` + - Start a dumb client that can receive actions from and return observations to an external controller client. + * - ``--record`` + - Start a recording client that returns observations to an external data gathering client. + * - ``--client-address`` + - Address to the controller / data gathering client. + * - ``--headless`` + - Start client in headless mode. + * - ``--sync-port`` + - Internal port used for syncing server and clients. + * - ``--sync-dtime`` + - Ingame time difference between steps when using server-client synchronization. + * - ``--noresizing`` + - Disallow screen resizing. + * - ``--cursor-image`` + - Path to cursor image file that is rendered at the mouse position in the dumb client mode. + +Below we focus on connecting a **server**, a **dumb client** and `Minetester's gymnasium environment. <../_api/minetester.minetest_env.html#minetester.minetest_env.Minetest>`_ + +To learn more about the other CLI options please refer to the :ref:`tutorials `. + +Sending random actions +---------------------- + +Let's manually start a server and a dumb client via + +.. code-block:: bash + + # remember how a server is started internally in singleplayer? + ./bin/minetest --go --dumb --client-address "tcp://localhost:5555" + + +We can set up a little Python script that acts as controller client using :py:class:`minetester.minetest_env.Minetest`, +which implements the gymnasium environment interface. + +.. literalinclude:: random_controller_loop.py + :language: python + :linenos: + +Make sure to provide matching ports in ``--client-address`` and ``env_port`` for connecting to the Minetest client. +Running this script should pop up two windows: one from the Minetest client and one from the Python controller rendering a randomly acting player. + +.. note:: + + By default ``start_minetest=True`` such that server and dumb client are started automatically as subprocesses. + +Further resources about Minetest +-------------------------------- + +- `minetest.net `_ +- `Minetest Wiki `_ +- `Minetest Forum `_ \ No newline at end of file diff --git a/docs-minetester/source/quickstart/installation.rst b/docs-minetester/source/quickstart/installation.rst new file mode 100644 index 0000000000000..23332d7037bb8 --- /dev/null +++ b/docs-minetester/source/quickstart/installation.rst @@ -0,0 +1,30 @@ +Installation +============ + +Building submodules, minetest and minetester +-------------------------------------------- + +Follow the build instructions: + +.. literalinclude:: ../../../build_instructions.txt + :language: bash + +You should see a window pop up with a player performing random actions in Minetest. + +To clean build artifacts run: + +.. code-block:: bash + + make clean + +Rebuilding minetester +--------------------- + +Use the following instructions to only rebuild minetester (not minetest): + +.. code-block:: bash + + make clean_minetester + make minetester + make install + make demo \ No newline at end of file diff --git a/docs-minetester/source/quickstart/minetest_conf_dummy.rst b/docs-minetester/source/quickstart/minetest_conf_dummy.rst new file mode 100644 index 0000000000000..21699253ef15b --- /dev/null +++ b/docs-minetester/source/quickstart/minetest_conf_dummy.rst @@ -0,0 +1,5 @@ +Minetest configuration file +=========================== + +.. literalinclude:: ../../../minetest.conf.example + :language: text diff --git a/docs-minetester/source/quickstart/random_controller_loop.py b/docs-minetester/source/quickstart/random_controller_loop.py new file mode 100644 index 0000000000000..5e17aadee0859 --- /dev/null +++ b/docs-minetester/source/quickstart/random_controller_loop.py @@ -0,0 +1,13 @@ +from minetester import Minetest + +mt = Minetest( + env_port=5555, + seed=0, + start_minetest=False +) +mt.reset() + +while True: + action = mt.action_space.sample() + mt.step(action) + mt.render() \ No newline at end of file diff --git a/docs-minetester/source/quickstart/what_is_minetester.rst b/docs-minetester/source/quickstart/what_is_minetester.rst new file mode 100644 index 0000000000000..1553152b04a9a --- /dev/null +++ b/docs-minetester/source/quickstart/what_is_minetester.rst @@ -0,0 +1,14 @@ +What is Minetester? +=================== + +Minetester extends the open-source voxel engine `Minetest `_ to support training and evaluation of AI / RL agents. +The Minetest core was modified to add the following main features: + +- a dumb client that adds an `API for controlling a player and receiving game information <../advanced/client_api.html>`_ +- support for `custom tasks using the Minetest modding API <../tutorials/create_task.html>`_ +- `headless client operation <../tutorials/headless_mode.html>`_ +- `client-server synchronization <../tutorials/synchronization.html>`_ + +In addition, the :py:mod:`minetester` Python package provides a gymnasium environment and utilities for communication with Minetest clients. + +For a motivation of the project, see the introductory `blog post `_. diff --git a/docs-minetester/source/tutorials/create_task.rst b/docs-minetester/source/tutorials/create_task.rst new file mode 100644 index 0000000000000..cab9f5a9247d7 --- /dev/null +++ b/docs-minetester/source/tutorials/create_task.rst @@ -0,0 +1,85 @@ +How to create a new task? +================================ + +Tasks are easy to create using Minetest's builtin Lua modding API. +The task-relevant information (rewards, task completion status, meta information, ...) are passed through +the Minetest processes and made available to the `client API <../advanced/client_api.html>`_. + +Task definition +--------------- + +A basic Minetest mod is defined by a directory with the following structure + +.. code-block:: + + my_task\ + mod.conf + init.lua + settingtypes.txt + +where `mod.conf` contains meta data, `init.lua` the necessary Lua code, and `settingtypes.txt` possible mod settings. +It is either located in the **clientmods** or the **mods** directory (see :ref:`below `). + +A task has to define or modify the following two global variables in the Lua script environment: + +1. ``REWARD``: a scalar floating point number indicating the reward received at the current step. +2. ``TERMINAL``: a boolean indicating whether the agent has reached a terminal state of the task. + +The variables can be changed at every step, or based on in-game events. + +.. note:: + + In order to avoid multiple definitions of ``REWARD`` and ``TERMINAL`` when using multiple task mods together, + there is a default mod called **rewards** (see `client-side rewards mod `_, + `server-side rewards mod `_) that takes care of the definition. If a mod with this name is found, + it will be automatically loaded. + +Example: A simple treechop task +------------------------------- + +The following files define a simple task, ``treechop-v0``, that rewards chopping trees and terminates after +a certain number of tree nodes have been chopped. + +`mod.conf` + +.. literalinclude :: ../../../clientmods/treechop_v0/mod.conf + :language: text + :linenos: + +`settingtypes.txt` + +.. literalinclude :: ../../../clientmods/treechop_v0/settingtypes.txt + :language: text + :linenos: + +`ìnit.lua` + +.. literalinclude :: ../../../clientmods/treechop_v0/init.lua + :language: lua + :linenos: + +.. _client_server_mods: + +Client and server mods +---------------------- + +Minetest provides two mod types, client-side mods located in the **clientmods** directory and server-side mods located in **mods**. + +In the default, asynchronous client-server operation tasks are specified as client-side mods, meaning each client +tracks its own reward and task termination variables. +One downside of client-side mods is that they don't have access to all information that is available on the server-side, +e.g. the inventory of the player. +In order to still obtain this information one can have an additional server-side mod and make use of so-called **mod channels** +to communicate the required data (see ``treechop-v1``: `client-side `_, +`server-side `_). + +On the other hand, when using `client-server synchronization `_, task data is distributed to the client +via the synchronization channel, such that **it is required** to exclusively use server mods for the task definition. +In this case, ``REWARD`` and ``TERMINAL`` are each tables of floats and booleans, respectively, containing values for each player name +(see ``treechop-v2``: `server-side only `_) + +Further resources +----------------- + +- `Minetest Modding API Reference `_ +- `Minetest Modding Forum `_ \ No newline at end of file diff --git a/docs-minetester/source/tutorials/headless_mode.rst b/docs-minetester/source/tutorials/headless_mode.rst new file mode 100644 index 0000000000000..7cb5af38f09f5 --- /dev/null +++ b/docs-minetester/source/tutorials/headless_mode.rst @@ -0,0 +1,43 @@ +Headless modes +============== + +There are two different modes, one using virtual framebuffer X server (`Xvfb `_) and one using SDL2's offscreen mode. + +Xvfb +~~~~ +In order to use the Xvfb headless mode, before starting the Minetest client, a new virtual framebuffer X server has to be started at an unused display number. +You can either use the Xvfb CLI: + +.. code-block:: bash + + export DISPLAY=:4 + export DISPLAY_WIDTH=1024 + export DISPLAY_HEIGHT=600 + export DISPLAY_DEPTH=24 + Xvfb $DISPLAY -screen 0 ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}x${DISPLAY_DEPTH} + +or this Python utiltity: :py:func:`minetester.utils.start_xserver` + + +In addition, the ``--headless`` runtime flag has to be passed to the Minetest client, e.g. + +.. code-block:: Python + + from minetester import Minetest + env = Minetest(headless=True) + +For convenience you can also tell the Minetest gym environment to start a Xvfb server: + +.. code-block:: Python + + from minetester import Minetest + env = Minetest(start_xvfb=True, headless=True) + +SDL2 offscreen mode +~~~~~~~~~~~~~~~~~~~ + +Using the SDL2-based headless mode requires compilation with the following build flags + +``-DBUILD_HEADLESS=1 -DSDL2_DIR=/SDL/build/lib/cmake/SDL2/`` + +It also requires the ``--headless`` runtime flag to be passed to the Minetest client. \ No newline at end of file diff --git a/docs-minetester/source/tutorials/synchronization.rst b/docs-minetester/source/tutorials/synchronization.rst new file mode 100644 index 0000000000000..265377989f9f9 --- /dev/null +++ b/docs-minetester/source/tutorials/synchronization.rst @@ -0,0 +1,30 @@ +Client-server synchronization +============================= + + +Motivation +---------- +Solid support of client-server synchronization is useful because of the following reasons: + +1. reproducibility of experiments + a. elimination of latency related differences in game state + b. direct loading of missing chunks (additional thread of the server) +2. eliminating communication overhead in the single-agent setting by running client and server in the same process +3. global shared game state in multi-agent setting + + +Current implementation +---------------------- + +The current implementation of client-server synchronization only covers case 1. a. and can be considered an experimental feature. +Instead of running the server process asynchronously it adds a message queue between client and server to operate them in lock step. +It makes the following assumption: + +- single agent (client) +- both processes run on the same machine +- task is specified in a server mod (see `task tutorial `_) + +There are two CLI arguments that need to be configured to make use of synchronization: + +1. ``--sync-port``: Internal port used for syncing server and client. Make sure to pass the same port to both processes. +2. ``--sync-dtime``: Ingame time difference between steps when using server-client synchronization. For example, sync-dtime = 0.05 seconds => 20 steps per second. The default walking speed in Minetest is 4 nodes / second, i.e. 0.2 nodes / step for this setting of sync-dtime. \ No newline at end of file diff --git a/games/minetest_game b/games/minetest_game index 960aff2f6366c..9e77e00c650db 160000 --- a/games/minetest_game +++ b/games/minetest_game @@ -1 +1 @@ -Subproject commit 960aff2f6366c2de864bdd9d4fd4f5eafa956bf3 +Subproject commit 9e77e00c650db7a49d45d94b429ff7be9fcec643 diff --git a/lib/SDL b/lib/SDL index f070c83a6059c..8030e7546a03f 160000 --- a/lib/SDL +++ b/lib/SDL @@ -1 +1 @@ -Subproject commit f070c83a6059c604cbd098680ddaee391b0a7341 +Subproject commit 8030e7546a03f08cdd3115d35e833030ebd45524 diff --git a/lib/irrlichtmt b/lib/irrlichtmt index 7e280633125a2..f178f25377a65 160000 --- a/lib/irrlichtmt +++ b/lib/irrlichtmt @@ -1 +1 @@ -Subproject commit 7e280633125a2bc0dfebdb06176001614883eac9 +Subproject commit f178f25377a654bc2133b26a89fd054b1ee8b387 diff --git a/minetest.conf b/minetest.conf new file mode 100644 index 0000000000000..c0bf9383d91b0 --- /dev/null +++ b/minetest.conf @@ -0,0 +1,14 @@ +screen_h = 842 +screen_w = 1387 +mainmenu_last_selected_world = 2 +selected_world_path = /home/rklassert/foss/minetest/bin/../worlds/world1 +update_last_known = +server_announce = false +enable_client_modding = true +enable_damage = true +creative_mode = false +update_last_checked = 1694871947 +menu_last_game = minetest +maintab_LAST = local +fps_max = 1000 +fps_max_unfocused = 1000 diff --git a/minetester/__init__.py b/minetester/__init__.py index 18d5bb972875d..bc151e2804e8e 100644 --- a/minetester/__init__.py +++ b/minetester/__init__.py @@ -1,10 +1,18 @@ +"""Minetester: expose Minetest as environment via the Gymnasium interface.""" + import gymnasium as gym -from minetester.minetest_env import Minetest + +from minetester.minetest_env import Minetest # noqa: F401 gym.register( id="Minetest-v0", entry_point=Minetest, nondeterministic=True, # TODO: check this and try to make it deterministic order_enforce=True, - kwargs={"base_seed": 42, "render_mode": "rgb_array", "headless": True, "start_xvfb": True}, -) \ No newline at end of file + kwargs={ + "base_seed": 42, + "render_mode": "rgb_array", + "headless": True, + "start_xvfb": True, + }, +) diff --git a/minetester/minetest_env.py b/minetester/minetest_env.py index 21af5439f70b7..4db8c553362ce 100644 --- a/minetester/minetest_env.py +++ b/minetester/minetest_env.py @@ -1,3 +1,4 @@ +"""Minetest Gymnasium Environment.""" import datetime import logging import os @@ -8,21 +9,24 @@ import gymnasium as gym import matplotlib.pyplot as plt import numpy as np +import pkg_resources import zmq + from minetester.utils import ( KEY_MAP, pack_pb_action, + read_config_file, start_minetest_client, start_minetest_server, - read_config_file, - write_config_file, - unpack_pb_obs, start_xserver, + unpack_pb_obs, + write_config_file, ) -import pkg_resources class Minetest(gym.Env): + """Minetest Gymnasium Environment.""" + metadata = {"render_modes": ["rgb_array", "human"], "render_fps": 20} default_display_size = (1024, 600) @@ -52,18 +56,53 @@ def __init__( x_display: Optional[int] = None, render_mode: str = "human", ): + """Initialize Minetest environment. + + Args: + env_port: Port between gym environment and a Minetest client + server_port: Port between Minetest client and server + minetest_root: Path to Minetest root + artefact_dir: Artefact directory, e.g. for logs, media cache, etc. + config_path: Path to minetest.conf + world_dir: Path to Minetest world directory + display_size: Size in pixels of the Minetest window + fov: Field of view in degrees of the Minetest window + base_seed: Seed for the Minetest environment. + world_seed: Fixed seed for world generation. If not set, world seeds + are randomly generated from `base_seed` at each `reset` + start_minetest: Whether to start Minetest server and client or + connect to existing processes + game_id: Name of the Minetest game + client_name: Name of the client/player. + clientmods: List of client mod names + servermods: List of server mod names + config_dict: Dictionary of config options updating the loaded config file + sync_port: Port between Minetest client and server for synchronization + sync_dtime: In-game time between two steps + dtime: Client-side in-game time between time steps + headless: Whether to run Minetest in headless mode + start_xvfb: Whether to start X server virtual framebuffer + x_display: Display number to use for the X server virtual framebuffer + render_mode: Gymnasium render mode. Supports 'human' and 'rgb_array'. + """ self.unique_env_id = str(uuid.uuid4()) - + # Graphics settings - self._set_graphics(headless, display_size, fov, render_mode) + self._set_graphics(headless, display_size, fov, render_mode) # Define action and observation space self._configure_spaces() # Define Minetest paths self.start_xvfb = start_xvfb and self.headless - self._set_artefact_dirs(artefact_dir, world_dir, config_path) # Stores minetest artefacts and outputs - self._set_minetest_dirs(minetest_root) # Stores actual minetest dirs and executable + self._set_artefact_dirs( + artefact_dir, + world_dir, + config_path, + ) # Stores minetest artefacts and outputs + self._set_minetest_dirs( + minetest_root, + ) # Stores actual minetest dirs and executable # Whether to start minetest server and client self.start_minetest = start_minetest @@ -76,8 +115,8 @@ def __init__( self.dtime = dtime self.sync_dtime = sync_dtime - #Client Name - self.client_name = client_name + # Client Name + self.client_name = client_name # ZMQ objects self.socket = None @@ -137,7 +176,7 @@ def __init__( if self.start_xvfb: self.x_display = x_display or self.default_display + 4 self.xserver_process = start_xserver(self.x_display, self.display_size) - + def _configure_spaces(self): # Define action and observation space self.max_mouse_move_x = self.display_size[0] @@ -162,7 +201,13 @@ def _configure_spaces(self): dtype=np.uint8, ) - def _set_graphics(self, headless: bool, display_size: Tuple[int, int], fov: int, render_mode: str): + def _set_graphics( + self, + headless: bool, + display_size: Tuple[int, int], + fov: int, + render_mode: str, + ): # gymnasium render mode self.render_mode = render_mode # minetest graphics settings @@ -174,35 +219,52 @@ def _set_graphics(self, headless: bool, display_size: Tuple[int, int], fov: int, def _set_minetest_dirs(self, minetest_root): self.minetest_root = minetest_root if self.minetest_root is None: - #check for local install + # check for local install candidate_minetest_root = os.path.dirname(os.path.dirname(__file__)) - candidate_minetest_executable = os.path.join(os.path.dirname(os.path.dirname(__file__)),"bin","minetest") + candidate_minetest_executable = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "bin", + "minetest", + ) if os.path.isfile(candidate_minetest_executable): self.minetest_root = candidate_minetest_root - if self.minetest_root is None: - #check for package install + if self.minetest_root is None: + # check for package install try: - candidate_minetest_executable = pkg_resources.resource_filename(__name__,os.path.join("minetest","bin","minetest")) + candidate_minetest_executable = pkg_resources.resource_filename( + __name__, + os.path.join("minetest", "bin", "minetest"), + ) if os.path.isfile(candidate_minetest_executable): - self.minetest_root = os.path.dirname(os.path.dirname(candidate_minetest_executable)) - except Exception as e: + self.minetest_root = os.path.dirname( + os.path.dirname(candidate_minetest_executable), + ) + except Exception as e: # noqa: B902 logging.warning(f"Error loading resource file 'bin.minetest': {e}") - + if self.minetest_root is None: raise Exception("Unable to locate minetest executable") - + if not self.start_xvfb and self.headless: - self.minetest_executable = os.path.join(self.minetest_root,"bin","minetest_headless") + self.minetest_executable = os.path.join( + self.minetest_root, + "bin", + "minetest_headless", + ) else: - self.minetest_executable = os.path.join(self.minetest_root,"bin","minetest") - + self.minetest_executable = os.path.join( + self.minetest_root, + "bin", + "minetest", + ) + self.cursor_image_path = os.path.join( self.minetest_root, "cursors", "mouse_cursor_white_16x16.png", ) - + def _set_artefact_dirs(self, artefact_dir, world_dir, config_path): if artefact_dir is None: self.artefact_dir = os.path.join(os.getcwd(), "artefacts") @@ -211,13 +273,16 @@ def _set_artefact_dirs(self, artefact_dir, world_dir, config_path): if config_path is None: self.clean_config = True - self.config_path = os.path.join(self.artefact_dir, f"{self.unique_env_id}.conf") + self.config_path = os.path.join( + self.artefact_dir, + f"{self.unique_env_id}.conf", + ) else: self.clean_config = True self.config_path = config_path - + if world_dir is None: - self.reset_world = True + self.reset_world = True self.world_dir = os.path.join(self.artefact_dir, self.unique_env_id) else: self.reset_world = False @@ -362,7 +427,7 @@ def _write_config(self): active_block_mgmt_interval=4.0, server_unload_unused_data_timeout=1000000, client_unload_unused_data_timeout=1000000, - full_block_send_enable_min_time_from_building=0., + full_block_send_enable_min_time_from_building=0.0, max_block_send_distance=100, max_block_generate_distance=100, num_emerge_threads=0, @@ -386,13 +451,30 @@ def _seed(self, seed: Optional[int] = None): # for reproducibility we only reset the RNG if it was not set before # or if a seed is provided to avoid the use of kernel RNG/time seeding # Note: kernel RNG/time seeding is still used if the inital seed=None - if self._np_random is None or (self._np_random is not None and seed is not None): + if self._np_random is None or ( + self._np_random is not None and seed is not None + ): self._np_random = np.random.default_rng(seed) - + def _sample_world_seed(self): self.world_seed = self._np_random.integers(np.iinfo(np.int64).max) - def reset(self, seed: Optional[int] = None, options: Optional[Dict[str, Any]] = None): + def reset( + self, + seed: Optional[int] = None, + options: Optional[Dict[str, Any]] = None, + ) -> Tuple[np.ndarray, Dict]: + """Reset the environment. + + Args: + seed: Seed for the environment. + e.g. used for deriving seeds for world generation. + options: Currently unused. + + Returns: + Tuple of inital observation and empty info dictionary. + """ + del options self._seed(seed=seed) if self.start_minetest: if self.reset_world: @@ -412,7 +494,19 @@ def reset(self, seed: Optional[int] = None, options: Optional[Dict[str, Any]] = logging.debug("Received first obs: {}".format(obs.shape)) return obs, {} - def step(self, action: Dict[str, Any]): + def step( + self, + action: Dict[str, Any], + ) -> Tuple[np.ndarray, float, bool, Dict[str, Any]]: + """Perform an action in the environment. + + Args: + action: The action to perform. + + Returns: + The next observation, the reward, whether the episode is truncated, + or done, and additional info. + """ # Send action if isinstance(action["MOUSE"], np.ndarray): action["MOUSE"] = action["MOUSE"].tolist() @@ -423,7 +517,8 @@ def step(self, action: Dict[str, Any]): pb_action = pack_pb_action(action) self.socket.send(pb_action.SerializeToString()) - # TODO more robust check for whether a server/client is alive while receiving observations + # TODO more robust check for whether a server/client + # is alive while receiving observations for process in [self.server_process, self.client_process]: if process is not None and process.poll() is not None: return self.last_obs, 0.0, True, False, {} @@ -440,7 +535,15 @@ def step(self, action: Dict[str, Any]): logging.debug(f"Received obs - {next_obs.shape}; reward - {rew}; info - {info}") return next_obs, rew, done, False, {"minetest_info": info} - def render(self): + def render(self) -> Optional[np.ndarray]: + """Render the environment. + + Returns: + Render image if `env.render_mode='rgb_array'`. + + Raises: + NotImplementedError: if `env.render_mode` not in ['human', 'rgb_array']. + """ if self.render_mode == "human": # TODO replace with pygame if self.render_img is None: @@ -465,10 +568,11 @@ def render(self): raise NotImplementedError( "You are calling 'render()' with an unsupported" f" render mode: '{self.render_mode}'. " - f"Supported modes: {self.metadata['render_modes']}" + f"Supported modes: {self.metadata['render_modes']}", ) - def close(self): + def close(self) -> None: + """Close the environment.""" if self.render_fig is not None: plt.close() if self.socket is not None: diff --git a/minetester/proto/__init__.py b/minetester/proto/__init__.py index e69de29bb2d1d..23d5556a5bc30 100644 --- a/minetester/proto/__init__.py +++ b/minetester/proto/__init__.py @@ -0,0 +1 @@ +"""Protobuf package for Minetester.""" diff --git a/minetester/scripts/__init__.py b/minetester/scripts/__init__.py index e69de29bb2d1d..4b7606ddc17b6 100644 --- a/minetester/scripts/__init__.py +++ b/minetester/scripts/__init__.py @@ -0,0 +1 @@ +"""Utility scripts for Minetester.""" diff --git a/minetester/scripts/gymnasium_api_check.py b/minetester/scripts/gymnasium_api_check.py index 20d085ee0c1ac..7e405f63513b0 100644 --- a/minetester/scripts/gymnasium_api_check.py +++ b/minetester/scripts/gymnasium_api_check.py @@ -1,11 +1,13 @@ -import minetester +"""Gymnasium API checker for Minetest environment.""" import gymnasium as gym from gymnasium.utils.env_checker import check_env +import minetester # noqa: F401 + env = gym.make("Minetest-v0") # Note: render check is skipped because it creates # a new environment for each render_mode without incrementing # the environment and server ports # TODO implement automatic port incrementation and check render -check_env(env.unwrapped, skip_render_check=True) \ No newline at end of file +check_env(env.unwrapped, skip_render_check=True) diff --git a/minetester/scripts/test_loop.py b/minetester/scripts/test_loop.py index 253608e6474b5..5ed5a72100f4e 100755 --- a/minetester/scripts/test_loop.py +++ b/minetester/scripts/test_loop.py @@ -1,32 +1,34 @@ -#!/usr/bin/env python3 -from gymnasium.wrappers import TimeLimit -from minetester import Minetest +"""Test loop for Minetest environment.""" +if __name__ == "__main__": + from gymnasium.wrappers import TimeLimit -render = True -max_steps = 100 + from minetester import Minetest -env = Minetest( - base_seed=42, - start_minetest=True, - headless=True, - start_xvfb=True, -) -env = TimeLimit(env, max_episode_steps=max_steps) + render = True + max_steps = 100 -env.reset() -done = False -step = 0 -while True: - try: - action = env.action_space.sample() - _, rew, done, truncated, info = env.step(action) - print(step, rew, done or truncated, info) - if render: - env.render() - if done or truncated: - env.reset() - step += 1 - except KeyboardInterrupt: - break -env.close() + env = Minetest( + base_seed=42, + start_minetest=True, + headless=True, + start_xvfb=True, + ) + env = TimeLimit(env, max_episode_steps=max_steps) + + env.reset() + done = False + step = 0 + while True: + try: + action = env.action_space.sample() + _, rew, done, truncated, info = env.step(action) + print(step, rew, done or truncated, info) + if render: + env.render() + if done or truncated: + env.reset() + step += 1 + except KeyboardInterrupt: + break + env.close() diff --git a/minetester/scripts/test_loop_parallel.py b/minetester/scripts/test_loop_parallel.py index 12bdce597f12d..c327bc69b4751 100644 --- a/minetester/scripts/test_loop_parallel.py +++ b/minetester/scripts/test_loop_parallel.py @@ -1,14 +1,16 @@ -import random -from typing import Any, Dict, Optional - -from gymnasium.wrappers import TimeLimit -from gymnasium.vector import AsyncVectorEnv -from minetester import Minetest -from minetester.utils import start_xserver +"""Test loop with parallel Minetest environments.""" if __name__ == "__main__": + import random + from typing import Any, Dict, Optional + + from gymnasium.vector import AsyncVectorEnv + from gymnasium.wrappers import TimeLimit - def make_env( + from minetester import Minetest + from minetester.utils import start_xserver + + def _make_env( rank: int, seed: int = 0, max_steps: int = 1e9, @@ -27,7 +29,10 @@ def _init(): **env_kwargs, ) # Assign random timelimit to check that resets work properly - env = TimeLimit(env, max_episode_steps=random.randint(max_steps // 2, max_steps)) + env = TimeLimit( + env, + max_episode_steps=random.randint(max_steps // 2, max_steps), + ) return env return _init @@ -48,7 +53,7 @@ def _init(): vec_env_cls = AsyncVectorEnv venv = vec_env_cls( [ - make_env(rank=i, seed=seed, max_steps=max_steps, env_kwargs=env_kwargs) + _make_env(rank=i, seed=seed, max_steps=max_steps, env_kwargs=env_kwargs) for i in range(num_envs) ], ) diff --git a/minetester/utils.py b/minetester/utils.py index 8b14317fb22e2..313e15acb638b 100644 --- a/minetester/utils.py +++ b/minetester/utils.py @@ -1,8 +1,10 @@ +"""Utility functions for Minetester.""" import os import subprocess -from typing import Any, Dict, Tuple, Optional +from typing import Any, Dict, Optional, Tuple import numpy as np + from minetester.proto import objects_pb2 as pb_objects from minetester.proto.objects_pb2 import KeyType @@ -37,7 +39,21 @@ NOOP_ACTION.update({"MOUSE": np.zeros(2, dtype=int)}) -def unpack_pb_obs(received_obs: str): +def unpack_pb_obs( + received_obs: str, +) -> Tuple[np.ndarray, float, bool, Dict[str, Any], Dict[str, int]]: + """Unpack a protobuf observation received from Minetest client. + + Note: here 'observation' encompasses all information received from the client + within one step and should not be confused with the observation + returned by a gym environment. + + Args: + received_obs: The received observation. + + Returns: + The displayed image, task reward, done flag, info dict and last action. + """ pb_obs = pb_objects.Observation() pb_obs.ParseFromString(received_obs) obs = np.frombuffer(pb_obs.image.data, dtype=np.uint8).reshape( @@ -52,7 +68,15 @@ def unpack_pb_obs(received_obs: str): return obs, rew, done, info, last_action -def unpack_pb_action(pb_action: pb_objects.Action): +def unpack_pb_action(pb_action: pb_objects.Action) -> Dict[str, int]: + """Unpack a protobuf action. + + Args: + pb_action: The protobuf action. + + Returns: + The unpacked action as dictionary. + """ action = dict(NOOP_ACTION) action["MOUSE"] = [pb_action.mouseDx, pb_action.mouseDy] for key_event in pb_action.keyEvents: @@ -62,7 +86,15 @@ def unpack_pb_action(pb_action: pb_objects.Action): return action -def pack_pb_action(action: Dict[str, Any]): +def pack_pb_action(action: Dict[str, Any]) -> pb_objects.Action: + """Pack a protobuf action. + + Args: + action: The action as dictionary. + + Returns: + The packed protobuf action. + """ pb_action = pb_objects.Action() pb_action.mouseDx, pb_action.mouseDy = action["MOUSE"] for key, v in action.items(): @@ -78,15 +110,30 @@ def pack_pb_action(action: Dict[str, Any]): def start_minetest_server( - minetest_path: str, - config_path: str, - log_path: str, - server_port: int, - world_dir: str, - sync_port: int, - sync_dtime: float, - game_id: str, -): + minetest_path: str = "bin/minetest", + config_path: str = "minetest.conf", + log_path: str = "log/{}.log", + server_port: int = 30000, + world_dir: str = "newworld", + sync_port: int = None, + sync_dtime: float = 0.001, + game_id: str = "minetest", +) -> subprocess.Popen: + """Start a Minetest server. + + Args: + minetest_path: Path to the Minetest executable. + config_path: Path to the minetest.conf file. + log_path: Path to the log files. + server_port: Port of the server. + world_dir: Path to the world directory. + sync_port: Port for the synchronization with the server. + sync_dtime: In-game time between two steps. + game_id: Game ID of the game to be used. + + Returns: + The server process. + """ cmd = [ minetest_path, "--server", @@ -120,12 +167,33 @@ def start_minetest_client( client_name: str, media_cache_dir: str, sync_port: Optional[int] = None, - dtime : Optional[float] = None, + dtime: Optional[float] = None, headless: bool = False, display: Optional[int] = None, set_gpu_vars: bool = True, set_vsync_vars: bool = True, -): +) -> subprocess.Popen: + """Start a Minetest client. + + Args: + minetest_path: Path to the Minetest executable. + config_path: Path to the minetest.conf file. + log_path: Path to the log files. + client_port: Port of the client. + server_port: Port of the server to connect to. + cursor_img: Path to the cursor image. + client_name: Name of the client. + media_cache_dir: Directory of minetest's media cache. + sync_port: Port for the synchronization with the server. + dtime: In-game time step in seconds. + headless: Whether to run the client in headless mode. + display: value of the DISPLAY variable. + set_gpu_vars: whether to enable Nvidia GPU usage + set_vsync_vars: whether to disable Vsync + + Returns: + The client process. + """ cmd = [ minetest_path, "--name", @@ -179,7 +247,17 @@ def start_xserver( display_idx: int = 1, display_size: Tuple[int, int] = (1024, 600), display_depth: int = 24, -): +) -> subprocess.Popen: + """Start a virtual framebuffer X server. + + Args: + display_idx: Value of the DISPLAY variable. + display_size: Size of the display. + display_depth: Depth of the display. + + Returns: + The X server process. + """ cmd = [ "Xvfb", f":{display_idx}", @@ -191,28 +269,43 @@ def start_xserver( return xserver_process -def read_config_file(file_path): +def read_config_file(file_path: os.PathLike): + """Read and parse minetest config files. + + Args: + file_path: Path to minetest config. + + Returns: + dictionary containing the parsed config. + """ config = {} - with open(file_path, 'r') as f: + with open(file_path, "r") as f: for line in f: line = line.strip() - if line and not line.startswith('#'): - key, value = line.split('=', 1) + if line and not line.startswith("#"): + key, value = line.split("=", 1) key = key.strip() value = value.strip() if value.isdigit(): value = int(value) - elif value.replace('.', '', 1).isdigit(): + elif value.replace(".", "", 1).isdigit(): value = float(value) - elif value.lower() == 'true': + elif value.lower() == "true": value = True - elif value.lower() == 'false': + elif value.lower() == "false": value = False config[key] = value return config -def write_config_file(file_path, config): - with open(file_path, 'w') as f: +def write_config_file(file_path: os.PathLike, config: Dict[str, Any]): + """Write a minetest config file. + + Args: + file_path: Write path for minetest config. + config: Dictionary representing minetest config. + + """ + with open(file_path, "w") as f: for key, value in config.items(): - f.write(f'{key} = {value}\n') + f.write(f"{key} = {value}\n") diff --git a/scripts/sync_server.sh b/scripts/sync_server.sh index 87812a1107b39..25a8d303956ab 100755 --- a/scripts/sync_server.sh +++ b/scripts/sync_server.sh @@ -1,2 +1,2 @@ #!/bin/bash -exec bin/minetest --server --world newworld --gameid minetest --sync-port 30010 --sync-dtime 0.1 --config hacking_testing/minetest.conf +exec bin/minetest --server --world newworld --gameid minetest --sync-port 30010 --sync-dtime 0.1 --config scripts/minetest.conf diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000000000..735473191b74b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[flake8] +max-line-length = 88 +extend-ignore = + # See https://github.com/PyCQA/pycodestyle/issues/373 + E203, \ No newline at end of file diff --git a/setup.py b/setup.py index d98d83467a928..35eba934592aa 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,15 @@ from setuptools import setup, find_packages +DEV = ["pre-commit", "black", "isort", "flake8"] +DOCS = [ + "sphinx==6.2.1", + "sphinx_rtd_theme==1.2.2", + "sphinx-autobuild", + "sphinx_autodoc_typehints", + "sphinx_copybutton", + "myst_parser", +] + setup( name='minetester', version='0.0.1', @@ -16,6 +26,7 @@ 'psutil', 'patchelf', ], + extras_require={"dev": DEV, "docs": DOCS}, package_data={ 'minetester': [ 'minetest/bin/minetest', diff --git a/src/client/clientlauncher.cpp b/src/client/clientlauncher.cpp index 43691ca4a6d3d..7fd34f5fe4cf3 100644 --- a/src/client/clientlauncher.cpp +++ b/src/client/clientlauncher.cpp @@ -350,6 +350,8 @@ void ClientLauncher::init_args(GameStartData &start_data, const Settings &cmd_ar if (cmd_args.exists("cursor-image")) start_data.cursor_image_path = cmd_args.get("cursor-image"); + else + start_data.cursor_image_path = ""; if (dumb && cmd_args.exists("sync-port")) start_data.sync_port = cmd_args.get("sync-port"); diff --git a/src/client/game.cpp b/src/client/game.cpp index c5c8d4ac0c952..85b21df493cde 100644 --- a/src/client/game.cpp +++ b/src/client/game.cpp @@ -905,7 +905,7 @@ class Game { zmqpp::socket* sync_socket = nullptr; // cursor image used in Gui recording - irr::video::IImage* cursorImage; + irr::video::IImage* cursorImage = nullptr; // custom dtime f32 custom_dtime = 0; @@ -1227,7 +1227,7 @@ bool Game::startup(bool *kill, if (start_data.isDumbClient()) { dynamic_cast(input)->socket = data_socket; } - if (start_data.isRecording()) { + if (start_data.isRecording() || start_data.isDumbClient()) { createRecorder(start_data); recorder->sender = data_socket; } diff --git a/util/minetester/build_irrlicht.sh b/util/minetester/build_irrlicht.sh new file mode 100644 index 0000000000000..b254e93a1d813 --- /dev/null +++ b/util/minetester/build_irrlicht.sh @@ -0,0 +1,4 @@ +cd lib/irrlichtmt +cmake . -DBUILD_SHARED_LIBS=OFF +make -j$(nproc) +cd ../.. \ No newline at end of file diff --git a/util/minetester/build_minetest.sh b/util/minetester/build_minetest.sh index f075a94f1455c..490c627e41cef 100755 --- a/util/minetester/build_minetest.sh +++ b/util/minetester/build_minetest.sh @@ -7,10 +7,11 @@ cd build/headless SDL2_DIR=${ROOT}/lib/SDL/build/lib/cmake/SDL2/ +IRRLICHTMT_DIR=${ROOT}/lib/irrlichtmt echo ${SDL2_DIR} -cmake ../.. -DRUN_IN_PLACE=TRUE -DBUILD_HEADLESS=1 -DSDL2_DIR=${SDL2_DIR} +cmake ../.. -DRUN_IN_PLACE=TRUE -DBUILD_HEADLESS=1 -DSDL2_DIR=${SDL2_DIR} -DIRRLICHTMT_BUILD_DIR=${IRRLICHTMT_DIR} make -j$(( $(nproc) > 1 ? $(nproc) - 1 : 1 )) #use max(nproc - 1,1) threads cd ../.. @@ -18,7 +19,9 @@ cd ../.. mv bin/minetest bin/minetest_headless cd build/normal -cmake ../.. -DRUN_IN_PLACE=TRUE -DBUILD_HEADLESS=0 -DSDL2_DIR= +cmake ../.. -DRUN_IN_PLACE=TRUE -DBUILD_HEADLESS=0 -DSDL2_DIR= -DIRRLICHTMT_BUILD_DIR=${IRRLICHTMT_DIR} make -j$(( $(nproc) > 1 ? $(nproc) - 1 : 1 )) #use max(nproc - 1,1) threads +cd ../.. + diff --git a/util/minetester/build_sdl2.sh b/util/minetester/build_sdl2.sh index b220e7e6c46d3..a509172afd616 100755 --- a/util/minetester/build_sdl2.sh +++ b/util/minetester/build_sdl2.sh @@ -2,3 +2,4 @@ cd lib/SDL mkdir build cd build ../configure --prefix=`pwd` && make && make install +cd ../../.. \ No newline at end of file diff --git a/util/minetester/build_zmqpp.sh b/util/minetester/build_zmqpp.sh index c244a2a829c36..a48b304973be8 100755 --- a/util/minetester/build_zmqpp.sh +++ b/util/minetester/build_zmqpp.sh @@ -1,4 +1,5 @@ cd lib/zmqpp -mkdir build +mkdir build && cd build cmake .. -G "Unix Makefiles" make +cd ../../..