Skip to content

Commit

Permalink
Initial craftium commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Mikel committed Jun 11, 2024
1 parent 728f643 commit 73faf18
Show file tree
Hide file tree
Showing 17 changed files with 921 additions and 17 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,6 @@ lib/irrlichtmt

# Generated mod storage database
client/mod_storage.sqlite

## Craftium
__pycache__
17 changes: 17 additions & 0 deletions craftium-docs/docs/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Craftium

Craftium is a fully open-source research platform for Reinforcement Learning (RL) research. Craftium provides a [Gymnasium](https://gymnasium.farama.org/index.html) wrapper for the [Minetest](https://www.minetest.net/) voxel game engine.

## Commands

* `mkdocs new [dir-name]` - Create a new project.
* `mkdocs serve` - Start the live-reloading docs server.
* `mkdocs build` - Build the documentation site.
* `mkdocs -h` - Print help message and exit.

## Project layout

mkdocs.yml # The configuration file.
docs/
index.md # The documentation homepage.
... # Other markdown pages, images and other files.
6 changes: 6 additions & 0 deletions craftium-docs/mkdocs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
site_name: Craftium

nav:
- Home: index.md

theme: readthedocs
1 change: 1 addition & 0 deletions craftium/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .env import CraftiumEnv
157 changes: 157 additions & 0 deletions craftium/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import os
from typing import Optional
import time

from .mt_client import MtClient
from .minetest import Minetest

import numpy as np

# import gymnasium as gym
from gymnasium import Env
from gymnasium.spaces import Dict, Discrete, Box


class CraftiumEnv(Env):
metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 30}

def __init__(
self,
obs_width: int = 640,
obs_height: int = 360,
init_frames: int = 15,
render_mode: Optional[str] = None,
max_timesteps: Optional[int] = None,
run_dir: Optional[os.PathLike] = None,
):
super(CraftiumEnv, self).__init__()

self.obs_width = obs_width
self.obs_height = obs_height
self.init_frames = init_frames
self.max_timesteps = max_timesteps

self.action_space = Dict({
"forward": Discrete(2),
"backward": Discrete(2),
"left": Discrete(2),
"right": Discrete(2),
"jump": Discrete(2),
"aux1": Discrete(2),
"sneak": Discrete(2),
"zoom": Discrete(2),
"dig": Discrete(2),
"place": Discrete(2),
"drop": Discrete(2),
"inventory": Discrete(2),
"slot_1": Discrete(2),
"slot_2": Discrete(2),
"slot_3": Discrete(2),
"slot_4": Discrete(2),
"slot_5": Discrete(2),
"slot_6": Discrete(2),
"slot_7": Discrete(2),
"slot_8": Discrete(2),
"slot_9": Discrete(2),
"mouse": Box(low=-1, high=1, shape=(2,), dtype=np.float32),
})

# names of the actions in the order they must be sent to MT
self.action_order = [
"forward", "backward", "left", "right", "jump", "aux1", "sneak",
"zoom", "dig", "place", "drop", "inventory", "slot_1", "slot_2",
"slot_3", "slot_4", "slot_5", "slot_6", "slot_7", "slot_8", "slot_9",
]

self.observation_space = Box(low=0, high=255, shape=(obs_width, obs_height, 3))

assert render_mode is None or render_mode in self.metadata["render_modes"]
self.render_mode = render_mode

# handles the MT configuration and process
self.mt = Minetest(
run_dir=run_dir,
headless=render_mode != "human",
)

# variable initialized in the `reset` method
self.client = None # client that connects to minetest

self.last_observation = None # used in render if "rgb_array"
self.timesteps = 0 # the timesteps counter

def _get_info(self):
return dict()

def reset(
self,
*,
seed: Optional[int] = None,
options: Optional[dict] = None,
):
super().reset(seed=seed)
self.timesteps = 0

# kill the active mt process and the python client if any
if self.client is not None:
self.client.close()
self.mt.kill_process()

# start the new MT process
self.mt.start_process() # launch the new MT process
time.sleep(2) # wait for MT to initialize (TODO Improve this)

# connect the client to the MT process
self.client = MtClient(
img_width=self.obs_width,
img_height=self.obs_height,
)

# HACK skip some frames to let the game initialize
for _ in range(self.init_frames):
_observation, _reward = self.client.receive()
self.client.send([0]*21, 0, 0) # nop action

observation, _reward = self.client.receive()
self.last_observation = observation

info = self._get_info()

return observation, info

def step(self, action):
self.timesteps += 1

# convert the action dict to a format to be sent to MT through mt_client
keys = [0]*21 # all commands (keys) except the mouse
mouse_x, mouse_y = 0, 0
for k, v in action.items():
if k == "mouse":
x, y = v[0], v[1]
mouse_x = int(x*(self.obs_width // 2))
mouse_y = int(y*(self.obs_height // 2))
else:
keys[self.action_order.index(k)] = v
# send the action to MT
self.client.send(keys, mouse_x, mouse_y)

# receive the new info from minetest
observation, reward = self.client.receive()
self.last_observation = observation

info = self._get_info()

# TODO Get the real termination info
terminated = False
truncated = self.max_timesteps is not None and self.timesteps >= self.max_timesteps

return observation, reward, terminated, truncated, info

def render(self):
if self.render_mode == "rgb_array":
return self.last_observation

def close(self):
self.mt.kill_process()
self.mt.clear()
self.client.close()
130 changes: 130 additions & 0 deletions craftium/minetest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import os
from typing import Optional, Any
import subprocess
import multiprocessing
from uuid import uuid4
import shutil
from distutils.dir_util import copy_tree


def launch_process(cmd: str, cwd: Optional[os.PathLike] = None):
def launch_fn():
stderr = open(os.path.join(cwd, "stderr.txt"), "w")
stdout = open(os.path.join(cwd, "stdout.txt"), "w")
subprocess.run(cmd, cwd=cwd, stderr=stderr, stdout=stdout)
process = multiprocessing.Process(target=launch_fn, args=[])
process.start()
return process


class Minetest():
def __init__(
self,
run_dir: Optional[os.PathLike] = None,
run_dir_prefix: Optional[os.PathLike] = None,
headless: bool = False,
seed: Optional[int] = None,
):
# create a dedicated directory for this run
if run_dir is None:
self.run_dir = f"./minetest-run-{uuid4()}"
if run_dir_prefix is not None:
self.run_dir = os.path.join(run_dir_prefix, self.run_dir)
else:
self.run_dir = run_dir
# delete the directory if it already exists
if os.path.exists(self.run_dir):
shutil.rmtree(self.run_dir)
# create the directory
os.mkdir(self.run_dir)

print(f"==> Creating Minetest run directory: {self.run_dir}")

config = dict(
# Base config
enable_sound=False,
show_debug=False,
enable_client_modding=True,
csm_restriction_flags=0,
enable_mod_channels=True,
screen_w=640,
screen_h=360,
vsync=False,
fps_max=1000,
fps_max_unfocused=1000,
undersampling=1000,
# fov=self.fov_y,
# game_dir=self.game_dir,

# Adapt HUD size to display size, based on (1024, 600) default
# hud_scaling=self.display_size[0] / 1024,

# Attempt to improve performance. Impact unclear.
server_map_save_interval=1000000,
profiler_print_interval=0,
active_block_range=2,
abm_time_budget=0.01,
abm_interval=0.1,
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.0,
max_block_send_distance=100,
max_block_generate_distance=100,
num_emerge_threads=0,
emergequeue_limit_total=1000000,
emergequeue_limit_diskonly=1000000,
emergequeue_limit_generate=1000000,
)
if seed is not None:
config["fixed_map_seed"] = seed

self._write_config(config, os.path.join(self.run_dir, "minetest.conf"))

# get the path location of the parent of this module (where all the minetest stuff is located)
root_path = os.path.dirname(os.path.dirname(__file__))

# create the directory tree structure needed by minetest
self._create_mt_dirs(root_dir=root_path, target_dir=self.run_dir)

self.launch_cmd = ["./bin/minetest", "--go"]

# set the env. variables to execute mintest in headless mode
if headless:
os.environ["SDL_VIDEODRIVER"] = "offscreen"

self.proc = None

def start_process(self):
self.proc = launch_process(self.launch_cmd, self.run_dir)

def kill_process(self):
self.proc.terminate()

def clear(self):
# delete the run's directory
if os.path.exists(self.run_dir):
shutil.rmtree(self.run_dir)

def _write_config(self, config: dict[str, Any], path: os.PathLike):
with open(path, "w") as f:
for key, value in config.items():
f.write(f"{key} = {value}\n")

def _create_mt_dirs(self, root_dir: os.PathLike, target_dir: os.PathLike):
def link_dir(name):
os.symlink(os.path.join(root_dir, name),
os.path.join(target_dir, name))
def copy_dir(name):
copy_tree(os.path.join(root_dir, name),
os.path.join(target_dir, name))

link_dir("builtin")
link_dir("fonts")
link_dir("locale")
link_dir("textures")
link_dir("bin")

copy_dir("worlds")
copy_dir("games")
copy_dir("client")
49 changes: 49 additions & 0 deletions craftium/mt_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import socket
import struct

import numpy as np

MT_IP = "127.0.0.1"
MT_PORT = 4343

class MtClient():
def __init__(self, img_width: int, img_height: int):
self.img_width = img_width
self.img_height = img_height

self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.s.connect((MT_IP, MT_PORT))

# pre-compute the number of bytes that we should receive from MT
self.rec_bytes = img_width*img_height*3 + 8 # the RGB image + 8 bytes of the reward

def receive(self):
data = []
while len(data) < self.rec_bytes:
data += self.s.recv(self.rec_bytes)

# decode the reward value
reward_bytes = bytes(data[-8:]) # the last 8 bytes
# uncpack the double (float in python) in native endianess
reward = struct.unpack("d", bytes(reward_bytes))[0]

# decode the observation RGB image
data = data[:-8] # get the image data, all bytes except the last 8
# reshape received bytes into an image
img = np.fromiter(
data,
dtype=np.uint8,
count=(self.rec_bytes-8)
).reshape(self.img_width, self.img_height, 3)

return img, reward

def send(self, keys: list[int], mouse_x: int, mouse_y: int):
assert len(keys) == 21, f"Keys list must be of length 21 and is {len(keys)}"

mouse = list(struct.pack("<h", mouse_x)) + list(struct.pack("<h", mouse_y))

self.s.sendall(bytes(keys + mouse))

def close(self):
self.s.close()
Loading

0 comments on commit 73faf18

Please sign in to comment.