Skip to content

Commit

Permalink
Switch minetest's and craftium's server/client roles
Browse files Browse the repository at this point in the history
  • Loading branch information
Mikel committed Jun 27, 2024
1 parent 03a431c commit 7af5e13
Show file tree
Hide file tree
Showing 5 changed files with 100 additions and 152 deletions.
60 changes: 23 additions & 37 deletions craftium/craftium_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Optional, Any
import time

from .mt_client import MtClient
from .mt_channel import MtChannel
from .minetest import Minetest

import numpy as np
Expand Down Expand Up @@ -35,6 +35,7 @@ class CraftiumEnv(Env):
:param minetest_dir: Path to the craftium's minetest build directory. If not given, defaults to the directory where craftium is installed. This option is intended for debugging purposes.
:param tcp_port: Port number used to communicate with minetest. If not provided a random free port in the range [49152, 65535] is selected.
:param minetest_conf: Extra configuration options added to the default minetest.conf file generated by craftium. Setting options here will overwrite default values. Check [mintest.conf.example](https://github.com/minetest/minetest/blob/master/minetest.conf.example) for all available configuration options.
:param pipe_proc: If `True`, the minetest process stderr and stdout will be piped into two files inside the run's directory. Otherwise, the minetest process will not be piped and its output will be shown in the terminal. This option is disabled by default to reduce verbosity, but can be useful for debugging.
"""
metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 30}

Expand All @@ -53,6 +54,7 @@ def __init__(
minetest_dir: Optional[str] = None,
tcp_port: Optional[int] = None,
minetest_conf: dict[str, Any] = dict(),
pipe_proc: bool = True,
):
super(CraftiumEnv, self).__init__()

Expand Down Expand Up @@ -88,10 +90,14 @@ def __init__(
minetest_dir=minetest_dir,
tcp_port=tcp_port,
minetest_conf=minetest_conf,
pipe_proc=pipe_proc,
)

# variable initialized in the `reset` method
self.client = None # client that connects to minetest
self.mt_chann = MtChannel(
img_width=self.obs_width,
img_height=self.obs_height,
port=self.mt.port,
)

self.last_observation = None # used in render if "rgb_array"
self.timesteps = 0 # the timesteps counter
Expand All @@ -115,41 +121,23 @@ def reset(
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()
# close the active (if any) channel with mintest
self.mt_chann.close_conn()
# kill the active mt process if there's any
self.mt.kill_process()

# start the new MT process
self.mt.start_process() # launch the new MT process

# connect the client to the MT process
try:
self.client = MtClient(
img_width=self.obs_width,
img_height=self.obs_height,
port=self.mt.port,
)
except Exception as e:
print("\n\n[!] Error connecting to Minetest. Minetest probably failed to launch.")
print(" => Run's scratch directory should be available, containing stderr.txt and stdout.txt useful for checking what went wrong.")
print(" Content of the stderr.txt file in the run's sratch directory:")
print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n")
with open(f"{self.mt.run_dir}/stderr.txt", "r") as f:
print(f.read())
print("\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~")
print("The raised exception (in case it's useful):")
print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n")
print(e)
quit(1)
self.mt.start_process()

# open communication channel with minetest
self.mt_chann.open_conn()

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

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

info = self._get_info()
Expand All @@ -165,7 +153,7 @@ def step(self, action):
"""
self.timesteps += 1

# convert the action dict to a format to be sent to MT through mt_client
# convert the action dict to a format to be sent to MT through mt_chann
keys = [0]*21 # all commands (keys) except the mouse
mouse_x, mouse_y = 0, 0
for k, v in action.items():
Expand All @@ -176,10 +164,10 @@ def step(self, action):
else:
keys[ACTION_ORDER.index(k)] = v
# send the action to MT
self.client.send(keys, mouse_x, mouse_y)
self.mt_chann.send(keys, mouse_x, mouse_y)

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

info = self._get_info()
Expand All @@ -193,8 +181,6 @@ def render(self):
return self.last_observation

def close(self):
if self.client is not None:
self.client.close()

self.mt_chann.close()
self.mt.kill_process()
self.mt.clear()
54 changes: 31 additions & 23 deletions craftium/minetest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,6 @@ def is_port_in_use(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(('localhost', port)) == 0


def launch_process(cmd: str, cwd: Optional[os.PathLike] = None, env_vars: dict[str, str] = dict()):
def launch_fn():
# set env vars
for key, value in env_vars.items():
os.environ[key] = value

# open files for piping stderr and stdout into
stderr = open(os.path.join(cwd, "stderr.txt"), "a")
stdout = open(os.path.join(cwd, "stdout.txt"), "a")

subprocess.run(cmd, cwd=cwd, stderr=stderr, stdout=stdout)
process = multiprocessing.Process(target=launch_fn, args=[])
process.start()
return process

def is_minetest_build_dir(path: os.PathLike) -> bool:
# list of directories required by craftium to exist in the a minetest build directory
req_dirs = ["builtin", "fonts", "locale", "textures", "bin", "client"]
Expand All @@ -53,7 +37,10 @@ def __init__(
minetest_dir: Optional[str] = None,
tcp_port: Optional[int] = None,
minetest_conf: dict[str, Any] = dict(),
pipe_proc: bool = True,
):
self.pipe_proc = pipe_proc

# create a dedicated directory for this run
if run_dir is None:
self.run_dir = f"./minetest-run-{uuid4()}"
Expand Down Expand Up @@ -147,22 +134,43 @@ def __init__(
"--worldname", world_name,
]

self.proc = None # will hold the mintest's process
self.proc = None # holds mintest's process
self.stderr, self.stdout = None, None

self.mt_env = {}
if headless:
self.mt_env["SDL_VIDEODRIVER"] = "offscreen"

def start_process(self):
self.proc = launch_process(
self.launch_cmd,
self.run_dir,
env_vars=self.mt_env
)
if self.pipe_proc:
# open files for piping stderr and stdout into
self.stderr = open(os.path.join(self.run_dir, "stderr.txt"), "a")
self.stdout = open(os.path.join(self.run_dir, "stdout.txt"), "a")

def launch_fn():
# set env vars
for key, value in self.mt_env.items():
os.environ[key] = value
# launch the process (pipeing stderr and stdout if necessary)
if self.pipe_proc:
subprocess.run(self.launch_cmd, cwd=self.run_dir, stderr=self.stderr, stdout=self.stdout)
else:
subprocess.run(self.launch_cmd, cwd=self.run_dir)

process = multiprocessing.Process(target=launch_fn, args=[])
process.start()
self.proc = process

def kill_process(self):
# close the files where the process is being piped
# into berfore the process itself
if self.stderr is not None:
self.stderr.close()
if self.stdout is not None:
self.stdout.close()

if self.proc is not None:
self.proc.terminate()
self.proc.kill()

def clear(self):
# delete the run's directory
Expand Down
41 changes: 21 additions & 20 deletions craftium/mt_client.py → craftium/mt_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,19 @@

import numpy as np

MT_IP = "127.0.0.1"
MT_DEFAULT_PORT = 4343
MT_DEFAULT_PORT = 55555

class MtClient():
class MtChannel():
def __init__(self, img_width: int, img_height: int, port: Optional[int] = None, connect_timeout: int = 30):
self.img_width = img_width
self.img_height = img_height

# create client's socket
self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.s.settimeout(10)

# make some trials to connect to the minetest server
trial_start = time.time()
while True:
time.sleep(0.1) # wait some time after each trial
try:
self.s.connect((MT_IP, MT_DEFAULT_PORT if port is None else port))
break
except Exception as e:
# check if the timeout is reached
if (time.time() - trial_start) >= connect_timeout:
print("[*] Craftium client reached timeout while waiting for minetest's server")
raise e
self.s.settimeout(30)
self.s.bind(("127.0.0.1", MT_DEFAULT_PORT if port is None else port))

# initialized in `reset_connection`
self.conn = None

# pre-compute the number of bytes that we should receive from MT.
# the RGB image + 8 bytes of the reward + 1 byte of the termination flag
Expand All @@ -37,7 +26,7 @@ def __init__(self, img_width: int, img_height: int, port: Optional[int] = None,
def receive(self):
data = []
while len(data) < self.rec_bytes:
data += self.s.recv(self.rec_bytes)
data += self.conn.recv(self.rec_bytes)
data = data[:self.rec_bytes]

# reward bytes (8) + termination bytes (1)
Expand Down Expand Up @@ -66,7 +55,19 @@ def send(self, keys: list[int], mouse_x: int, mouse_y: int):

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

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

def close(self):
if self.conn is not None:
self.conn.close()
self.s.close()

def close_conn(self):
if self.conn is not None:
self.conn.close()
self.conn = None

def open_conn(self):
self.close_conn()
self.s.listen()
self.conn, addr = self.s.accept()
Loading

0 comments on commit 7af5e13

Please sign in to comment.