mudgym 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mudgym/__init__.py +24 -0
- mudgym/actions.py +11 -0
- mudgym/connections/__init__.py +0 -0
- mudgym/connections/config.py +17 -0
- mudgym/connections/connection.py +150 -0
- mudgym/connections/docker_exec.py +152 -0
- mudgym/connections/docker_image.py +98 -0
- mudgym/connections/docker_run.py +95 -0
- mudgym/connections/persona.py +47 -0
- mudgym/connections/prompts.py +298 -0
- mudgym/connections/provider.py +250 -0
- mudgym/connections/recording.py +218 -0
- mudgym/connections/registry.py +81 -0
- mudgym/connections/state_machine.py +533 -0
- mudgym/connections/termination.py +47 -0
- mudgym/connections/transitions.py +234 -0
- mudgym/db/directions.py +38 -0
- mudgym/db/index.py +122 -0
- mudgym/db/levels.py +68 -0
- mudgym/db/rooms.py +1156 -0
- mudgym/db/weather.py +15 -0
- mudgym/envs/__init__.py +0 -0
- mudgym/envs/actions/__init__.py +0 -0
- mudgym/envs/actions/discrete.py +53 -0
- mudgym/envs/env.py +375 -0
- mudgym/envs/factory.py +273 -0
- mudgym/envs/fields/__init__.py +31 -0
- mudgym/envs/fields/feinventory.py +75 -0
- mudgym/envs/fields/fescore.py +140 -0
- mudgym/envs/fields/fexits.py +92 -0
- mudgym/envs/fields/field.py +126 -0
- mudgym/envs/fields/mgcheats.py +113 -0
- mudgym/envs/fields/rawbytes.py +52 -0
- mudgym/envs/fields/superquicklook.py +244 -0
- mudgym/envs/registration.py +30 -0
- mudgym/envs/specs.py +56 -0
- mudgym/envs/validation.py +30 -0
- mudgym/envs/vector.py +43 -0
- mudgym/envs/zoo.py +229 -0
- mudgym/featurizers/ansi.py +25 -0
- mudgym/featurizers/persona_names.py +32 -0
- mudgym/featurizers/points.py +57 -0
- mudgym/featurizers/quickscore.py +44 -0
- mudgym/featurizers/responses.py +114 -0
- mudgym/featurizers/strings.py +58 -0
- mudgym/logs.py +273 -0
- mudgym/notebooks/__init__.py +39 -0
- mudgym/notebooks/frames.py +378 -0
- mudgym/notebooks/panels.py +144 -0
- mudgym/notebooks/room_map.py +391 -0
- mudgym/notebooks/style.py +65 -0
- mudgym/notebooks/tables.py +177 -0
- mudgym/session.py +247 -0
- mudgym-0.3.0.dist-info/METADATA +69 -0
- mudgym-0.3.0.dist-info/RECORD +58 -0
- mudgym-0.3.0.dist-info/WHEEL +4 -0
- mudgym-0.3.0.dist-info/licenses/LICENSE +21 -0
- mudgym-0.3.0.dist-info/licenses/NOTICE +13 -0
mudgym/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MudGym: a reinforcement learning environment for MUD2.
|
|
3
|
+
|
|
4
|
+
Importing this package registers the Gymnasium env IDs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
from mudgym.envs.factory import make_env, make_parallel_env, make_vector_env
|
|
10
|
+
from mudgym.envs.registration import register_envs as _register_envs
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
__version__ = version("mudgym")
|
|
14
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
15
|
+
__version__ = "0.0.0.dev0"
|
|
16
|
+
|
|
17
|
+
_register_envs()
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"make_env",
|
|
22
|
+
"make_parallel_env",
|
|
23
|
+
"make_vector_env",
|
|
24
|
+
]
|
mudgym/actions.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Public action vocabulary helpers."""
|
|
2
|
+
|
|
3
|
+
from mudgym.db.directions import DIRECTION_INDEX_BY_NAME, DIRECTIONS
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def direction_index(direction: str) -> int:
|
|
7
|
+
"""Return the zero-based action and exit-mask index for a direction."""
|
|
8
|
+
return DIRECTION_INDEX_BY_NAME[direction]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["DIRECTIONS", "direction_index"]
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
DOCKER_IMAGE = os.getenv("DOCKER_IMAGE", "ghcr.io/rolo/mudgym:v0.2.3")
|
|
4
|
+
CONTAINER_PREFIX = "mudgym"
|
|
5
|
+
DEFAULT_DOCKER_EXEC_CONTAINER_NAME = "mud2-boot"
|
|
6
|
+
|
|
7
|
+
# connection defaults
|
|
8
|
+
DEFAULT_ACCOUNT_ID = "W00000001"
|
|
9
|
+
DEFAULT_PASSWORD = "password"
|
|
10
|
+
|
|
11
|
+
# first listed in AVAILABLE_CONNECTIONS is default
|
|
12
|
+
AVAILABLE_CONNECTIONS = os.getenv("AVAILABLE_CONNECTIONS", "docker_run,docker_exec")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def configured_docker_exec_container_name() -> str:
|
|
16
|
+
"""Return the shared-container name configured for this process."""
|
|
17
|
+
return os.getenv("MUDGYM_DOCKER_EXEC_CONTAINER_NAME", DEFAULT_DOCKER_EXEC_CONTAINER_NAME)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Callable, Sequence
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pexpect
|
|
6
|
+
|
|
7
|
+
from mudgym.connections.prompts import FINAL_COMMAND_MARKER, PromptSpec, State
|
|
8
|
+
from mudgym.connections.state_machine import ConnectionState
|
|
9
|
+
from mudgym.logs import get_logger
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MudConnection:
|
|
15
|
+
"""
|
|
16
|
+
Base class for managing connections to MUD2 instances.
|
|
17
|
+
|
|
18
|
+
The connection lifecycle is managed by the state machine, this class wraps the state machine and allows for
|
|
19
|
+
different types of transport whilst exposing a (hopefully) easier to deal with API.
|
|
20
|
+
|
|
21
|
+
Lifecycle:
|
|
22
|
+
- reset() -> Get us to the TEA_SIPPED state, ready for an episode to begin.
|
|
23
|
+
- send_command(command: str) -> tuple[bytes, bool, bool, dict] -> Send a command to the game, receiving the
|
|
24
|
+
raw response bytes, terminated and incomplete flags and some debug info.
|
|
25
|
+
- close() -> Close the connection, terminating the child process. Typically you would use reset() instead if you
|
|
26
|
+
are going to want to reuse the connection to start a new episode (for connections that support it).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# initial prompt we expect to see - subclasses can override
|
|
30
|
+
initial_prompt: PromptSpec | None = None
|
|
31
|
+
|
|
32
|
+
# the end of turn marker closing each step's read window: the pattern identifying the response of the batch's
|
|
33
|
+
# final command. This class attribute is the protocol's one declared default (fei's ======== divider), which
|
|
34
|
+
# keeps a bare connection usable on its own; the session overrides it per instance with whatever marker the
|
|
35
|
+
# env's batch actually ends with.
|
|
36
|
+
end_of_turn_marker: re.Pattern = FINAL_COMMAND_MARKER
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
account_id: str = "",
|
|
42
|
+
password: str = "",
|
|
43
|
+
persona_slot: int | None = None,
|
|
44
|
+
db_slot: int | None = None,
|
|
45
|
+
name_generator: Callable[[], str] | None = None,
|
|
46
|
+
):
|
|
47
|
+
# I'm not convinced we actually need many of these anymore, but they're here for now.
|
|
48
|
+
self.account_id = account_id
|
|
49
|
+
self.password = password
|
|
50
|
+
self.persona_slot = persona_slot
|
|
51
|
+
self.db_slot = db_slot
|
|
52
|
+
self.name_generator = name_generator
|
|
53
|
+
|
|
54
|
+
# our state machine instance, that does most of the heavy lifting
|
|
55
|
+
self.sm: ConnectionState | None = None
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def is_available(cls) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Check if the connection is available to use in the current environment. Subclasses can override this method to
|
|
61
|
+
perform a check specific to the connection type. This is to help with experimenting with different connections
|
|
62
|
+
types.
|
|
63
|
+
"""
|
|
64
|
+
logger.debug("connection.is_available.default", connection_class=cls.__name__)
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
def spawn(self) -> pexpect.spawn:
|
|
68
|
+
"""
|
|
69
|
+
The method that does the actual connecting by spawning and returning our child process.
|
|
70
|
+
"""
|
|
71
|
+
return pexpect.spawn(
|
|
72
|
+
self.command[0],
|
|
73
|
+
self.command[1:] if len(self.command) > 1 else [],
|
|
74
|
+
encoding=None,
|
|
75
|
+
use_poll=True, # poll() instead of select() to avoid FD_SETSIZE limit
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def reset(self) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Resets the connection to be ready to start a new episode (TEA_SIPPED state).
|
|
81
|
+
|
|
82
|
+
This tells us the `MudConnection` is ready but the `MudEnv` has its own `reset()` steps afterwards that does
|
|
83
|
+
episode related things that don't make sense here, like issuing commands to set up the initial environment state
|
|
84
|
+
(eg, score) and running start of episode autocommands and taking the northwards step outside of the tearoom.
|
|
85
|
+
|
|
86
|
+
I can imagine a situation with multiple `MudConnection`s waiting on each other after `reset()` to be ready so it
|
|
87
|
+
seemed negligent to leave agents hanging around outside of the sanctity of the Tearoom where they might get
|
|
88
|
+
attacked by mobiles or something.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
# do we need to respawn the process or can we reuse via some menu choices?
|
|
92
|
+
needs_respawn = self.sm is None or not self.sm.isalive()
|
|
93
|
+
logger.debug(
|
|
94
|
+
"connection.reset.start",
|
|
95
|
+
sm_state=self.sm.state.name if self.sm is not None else None,
|
|
96
|
+
needs_respawn=needs_respawn,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if self.sm is not None and self.sm.isalive():
|
|
100
|
+
# reset-quit: leave The Land but stay in the mudlogin menu if our connection type
|
|
101
|
+
# supports that (ie, not a quicklogin, which exits to DEAD)
|
|
102
|
+
self.sm.quit()
|
|
103
|
+
|
|
104
|
+
if self.sm.state == State.DEAD:
|
|
105
|
+
needs_respawn = True
|
|
106
|
+
|
|
107
|
+
if needs_respawn:
|
|
108
|
+
logger.debug("connection.reset.spawn")
|
|
109
|
+
child = self.spawn()
|
|
110
|
+
self.sm = ConnectionState(
|
|
111
|
+
child=child,
|
|
112
|
+
account_id=self.account_id,
|
|
113
|
+
password=self.password,
|
|
114
|
+
persona_slot=self.persona_slot,
|
|
115
|
+
db_slot=self.db_slot,
|
|
116
|
+
name_generator=self.name_generator,
|
|
117
|
+
initial_prompt=self.initial_prompt,
|
|
118
|
+
end_of_turn_marker=self.end_of_turn_marker,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# OPTION -> persona selection/creation -> TEAROOM -> sip tea -> TEA_SIPPED
|
|
122
|
+
logger.debug("connection.reset.continue_until_tea", sm_state=self.sm.state.name)
|
|
123
|
+
self.sm.continue_until(State.TEA_SIPPED)
|
|
124
|
+
logger.debug(
|
|
125
|
+
"connection.reset.complete",
|
|
126
|
+
sm_state=self.sm.state.name,
|
|
127
|
+
last_prompt=self.sm.last_prompt.name if self.sm.last_prompt else None,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def send_command(self, command: str | Sequence[str]) -> tuple[bytes, bool, bool, dict[str, Any]]:
|
|
131
|
+
"""
|
|
132
|
+
Send a command batch (one wire line, or several when the caller split it) and return the
|
|
133
|
+
raw response bytes, terminated/incomplete flags, and debug info.
|
|
134
|
+
"""
|
|
135
|
+
if self.sm is None:
|
|
136
|
+
raise RuntimeError("Connection has not been reset, call reset() first.")
|
|
137
|
+
lines = [command] if isinstance(command, str) else list(command)
|
|
138
|
+
if not lines:
|
|
139
|
+
raise ValueError("send_command requires at least one command line; got an empty batch")
|
|
140
|
+
return self.sm.send_command(lines)
|
|
141
|
+
|
|
142
|
+
def close(self):
|
|
143
|
+
if self.sm is None:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
if self.sm.isalive():
|
|
147
|
+
self.sm.quit()
|
|
148
|
+
finally:
|
|
149
|
+
self.sm.close()
|
|
150
|
+
self.sm = None
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
|
|
4
|
+
from mudgym.connections.config import (
|
|
5
|
+
DEFAULT_ACCOUNT_ID,
|
|
6
|
+
DEFAULT_PASSWORD,
|
|
7
|
+
DOCKER_IMAGE,
|
|
8
|
+
configured_docker_exec_container_name,
|
|
9
|
+
)
|
|
10
|
+
from mudgym.connections.connection import MudConnection
|
|
11
|
+
from mudgym.connections.docker_image import ensure_docker_image
|
|
12
|
+
from mudgym.connections.prompts import Prompt, PromptSpec
|
|
13
|
+
from mudgym.logs import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DockerExecConnection(MudConnection):
|
|
19
|
+
"""
|
|
20
|
+
MUD2 connection that execs into an existing Docker container.
|
|
21
|
+
|
|
22
|
+
Typically for using a single container running with multiple game slots, or when connecting
|
|
23
|
+
multiple clients to the same game slot.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
initial_prompt: PromptSpec = [
|
|
27
|
+
Prompt.OPTION,
|
|
28
|
+
Prompt.SUPERSEDE,
|
|
29
|
+
Prompt.SESSION_DYING,
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
container_name: str | None = None,
|
|
35
|
+
container_id: str | None = None,
|
|
36
|
+
start_if_missing: bool = True,
|
|
37
|
+
container_image: str = DOCKER_IMAGE,
|
|
38
|
+
*,
|
|
39
|
+
use_tty: bool = True,
|
|
40
|
+
account_id: str = DEFAULT_ACCOUNT_ID,
|
|
41
|
+
password: str = DEFAULT_PASSWORD,
|
|
42
|
+
persona_slot: int | None = None,
|
|
43
|
+
db_slot: int | None = None,
|
|
44
|
+
name_generator: Callable[[], str] | None = None,
|
|
45
|
+
):
|
|
46
|
+
container_name = container_name if container_name is not None else configured_docker_exec_container_name()
|
|
47
|
+
# if container_id is not provided, find the first container with the given prefix
|
|
48
|
+
self.container_image = container_image
|
|
49
|
+
self.container_name = container_name
|
|
50
|
+
# track whether we started the container and own the lifecycle
|
|
51
|
+
self._started_container = False
|
|
52
|
+
self.container_id = container_id or self.find_container_id(container_name, start_if_missing)
|
|
53
|
+
self.use_tty = use_tty
|
|
54
|
+
|
|
55
|
+
super().__init__(
|
|
56
|
+
account_id=account_id,
|
|
57
|
+
password=password,
|
|
58
|
+
persona_slot=persona_slot,
|
|
59
|
+
db_slot=db_slot,
|
|
60
|
+
name_generator=name_generator,
|
|
61
|
+
)
|
|
62
|
+
self.command = self.build_command()
|
|
63
|
+
|
|
64
|
+
def find_container_id(self, name: str, start_if_missing: bool = True) -> str:
|
|
65
|
+
"""Find container ID by name."""
|
|
66
|
+
out = subprocess.check_output(["docker", "ps", "-qf", f"name={name}"], text=True).strip()
|
|
67
|
+
if out:
|
|
68
|
+
# if multiple lines, pick first:
|
|
69
|
+
return out.splitlines()[0]
|
|
70
|
+
if start_if_missing:
|
|
71
|
+
logger.debug("docker.container.not_found", name=name, start_if_missing=start_if_missing)
|
|
72
|
+
return self.start_container()
|
|
73
|
+
raise RuntimeError(f"No running container matches name={name}; start_if_missing={start_if_missing}")
|
|
74
|
+
|
|
75
|
+
def start_container(self, slots: int = 1) -> str:
|
|
76
|
+
"""Start a new container and return the container ID."""
|
|
77
|
+
ensure_docker_image(self.container_image)
|
|
78
|
+
output = subprocess.check_output(
|
|
79
|
+
[
|
|
80
|
+
"docker",
|
|
81
|
+
"run",
|
|
82
|
+
"--init",
|
|
83
|
+
"--rm",
|
|
84
|
+
"-d",
|
|
85
|
+
#'--ipc="private"',
|
|
86
|
+
"--name",
|
|
87
|
+
self.container_name,
|
|
88
|
+
self.container_image,
|
|
89
|
+
"/bin/sh",
|
|
90
|
+
"-lc",
|
|
91
|
+
f"/app/bin/boot -n {slots} -f -k",
|
|
92
|
+
],
|
|
93
|
+
text=True,
|
|
94
|
+
).strip()
|
|
95
|
+
|
|
96
|
+
# docker run -d prints the container ID on stdout
|
|
97
|
+
container_id = output.splitlines()[-1] if output else ""
|
|
98
|
+
if not container_id:
|
|
99
|
+
raise RuntimeError(f"docker run did not return a container id for {self.container_name}")
|
|
100
|
+
|
|
101
|
+
logger.debug("docker.container.started", container_id=container_id)
|
|
102
|
+
self.container_id = container_id
|
|
103
|
+
self._started_container = True
|
|
104
|
+
return self.container_id
|
|
105
|
+
|
|
106
|
+
def build_command(self) -> list[str]:
|
|
107
|
+
cmd = [
|
|
108
|
+
"docker",
|
|
109
|
+
"exec",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
# only add TTY flag if we have a TTY (needed for GitHub Actions)
|
|
113
|
+
if self.use_tty:
|
|
114
|
+
cmd.append("-it")
|
|
115
|
+
else:
|
|
116
|
+
cmd.append("-i")
|
|
117
|
+
|
|
118
|
+
cmd.extend(
|
|
119
|
+
[
|
|
120
|
+
"-e",
|
|
121
|
+
f"L0={self.account_id}",
|
|
122
|
+
"-e",
|
|
123
|
+
f"L1={self.password}",
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
cmd.extend([self.container_id, "/app/bin/mudlogin", "-n"])
|
|
128
|
+
|
|
129
|
+
return cmd
|
|
130
|
+
|
|
131
|
+
def close(self):
|
|
132
|
+
"""Close the exec session, and remove the container if this connection started it.
|
|
133
|
+
|
|
134
|
+
We only tear down the container we own (one we started because none was running). A
|
|
135
|
+
pre-existing, shared container is left alone so other clients exec'd into it survive.
|
|
136
|
+
"""
|
|
137
|
+
super().close()
|
|
138
|
+
|
|
139
|
+
if not self._started_container:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
subprocess.run(
|
|
144
|
+
["docker", "rm", "-f", self.container_name],
|
|
145
|
+
capture_output=True,
|
|
146
|
+
check=False, # Don't raise if the container is already gone
|
|
147
|
+
)
|
|
148
|
+
logger.debug("docker.container.removed", container_name=self.container_name)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.debug("docker.container.remove.failed", container_name=self.container_name, error=str(e))
|
|
151
|
+
finally:
|
|
152
|
+
self._started_container = False
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Fail-fast Docker preflight shared by the Docker-backed connections.
|
|
2
|
+
|
|
3
|
+
An uncached image pull can never fit inside the game's first-prompt timeout,
|
|
4
|
+
so the image must be present before any container spawns. This module makes
|
|
5
|
+
each failure mode explicit instead of surfacing a raw pexpect timeout: no
|
|
6
|
+
Docker binary, an unreachable daemon, a registry that refuses access, and a
|
|
7
|
+
tag that does not exist.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from mudgym.logs import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
INSPECT_TIMEOUT_SECONDS = 30
|
|
18
|
+
PULL_TIMEOUT_SECONDS = 600
|
|
19
|
+
|
|
20
|
+
# Images verified once per process; repeated spawns skip the inspect subprocess.
|
|
21
|
+
_verified_images: set[str] = set()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DockerSetupError(RuntimeError):
|
|
25
|
+
"""Docker or the game image is unusable; the message says how to fix it."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_docker_image(image_name: str) -> None:
|
|
29
|
+
"""Make sure ``image_name`` is usable locally, pulling it on first use."""
|
|
30
|
+
if image_name in _verified_images:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
docker = shutil.which("docker")
|
|
34
|
+
if docker is None:
|
|
35
|
+
raise DockerSetupError(
|
|
36
|
+
"Docker is required to run the MudGym game but no `docker` executable was found on PATH. "
|
|
37
|
+
"Install Docker (https://docs.docker.com/get-docker/) and try again."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
inspect = subprocess.run(
|
|
42
|
+
[docker, "image", "inspect", image_name],
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=INSPECT_TIMEOUT_SECONDS,
|
|
46
|
+
)
|
|
47
|
+
except subprocess.TimeoutExpired as error:
|
|
48
|
+
raise DockerSetupError(
|
|
49
|
+
f"Docker did not answer `docker image inspect` within {INSPECT_TIMEOUT_SECONDS}s. "
|
|
50
|
+
"Check that the Docker daemon is healthy and try again."
|
|
51
|
+
) from error
|
|
52
|
+
|
|
53
|
+
if inspect.returncode != 0:
|
|
54
|
+
inspect_error = inspect.stderr.strip()
|
|
55
|
+
lowered = inspect_error.lower()
|
|
56
|
+
# A missing image also mentions the daemon ("Error response from daemon:
|
|
57
|
+
# No such image"), so only treat connection failures as daemon-down.
|
|
58
|
+
if "cannot connect" in lowered or "is the docker daemon running" in lowered:
|
|
59
|
+
raise DockerSetupError(
|
|
60
|
+
f"Docker is installed but its daemon is not reachable: {inspect_error} Start Docker and try again."
|
|
61
|
+
)
|
|
62
|
+
_pull_image(docker, image_name)
|
|
63
|
+
|
|
64
|
+
_verified_images.add(image_name)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _pull_image(docker: str, image_name: str) -> None:
|
|
68
|
+
logger.info("docker.image.pull.start", image=image_name)
|
|
69
|
+
try:
|
|
70
|
+
pull = subprocess.run(
|
|
71
|
+
[docker, "pull", image_name],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
timeout=PULL_TIMEOUT_SECONDS,
|
|
75
|
+
)
|
|
76
|
+
except subprocess.TimeoutExpired as error:
|
|
77
|
+
raise DockerSetupError(
|
|
78
|
+
f"Pulling the MudGym game image {image_name!r} did not finish within "
|
|
79
|
+
f"{PULL_TIMEOUT_SECONDS}s. Check your network connection and try again, or run "
|
|
80
|
+
f"`docker pull {image_name}` yourself to watch its progress."
|
|
81
|
+
) from error
|
|
82
|
+
|
|
83
|
+
if pull.returncode == 0:
|
|
84
|
+
logger.info("docker.image.pull.done", image=image_name)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
pull_error = pull.stderr.strip()
|
|
88
|
+
lowered = pull_error.lower()
|
|
89
|
+
if "denied" in lowered or "unauthorized" in lowered or "authentication" in lowered:
|
|
90
|
+
detail = (
|
|
91
|
+
"The registry refused access to it. If this is a released MudGym version the image is "
|
|
92
|
+
"public and no login is needed, so check for a stale `docker login ghcr.io` credential."
|
|
93
|
+
)
|
|
94
|
+
elif "manifest unknown" in lowered or "not found" in lowered:
|
|
95
|
+
detail = "That tag does not exist on the registry, so check the image name and tag."
|
|
96
|
+
else:
|
|
97
|
+
detail = "Check your network connection and the Docker daemon logs."
|
|
98
|
+
raise DockerSetupError(f"Could not pull the MudGym game image {image_name!r}. {detail}\nDocker said: {pull_error}")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import uuid
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
import pexpect
|
|
6
|
+
|
|
7
|
+
from mudgym.connections.config import (
|
|
8
|
+
CONTAINER_PREFIX,
|
|
9
|
+
DEFAULT_ACCOUNT_ID,
|
|
10
|
+
DEFAULT_PASSWORD,
|
|
11
|
+
DOCKER_IMAGE,
|
|
12
|
+
)
|
|
13
|
+
from mudgym.connections.connection import MudConnection
|
|
14
|
+
from mudgym.connections.docker_image import ensure_docker_image
|
|
15
|
+
from mudgym.connections.prompts import Prompt, PromptSpec
|
|
16
|
+
from mudgym.logs import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DockerRunConnection(MudConnection):
|
|
22
|
+
"""
|
|
23
|
+
Docker run connection.
|
|
24
|
+
|
|
25
|
+
Spins up a new container via `docker run` for each connection.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
initial_prompt: PromptSpec = Prompt.OPTION
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
container_name: str | None = None,
|
|
33
|
+
image_name: str | None = DOCKER_IMAGE,
|
|
34
|
+
use_tty: bool = True,
|
|
35
|
+
*,
|
|
36
|
+
account_id: str = DEFAULT_ACCOUNT_ID,
|
|
37
|
+
password: str = DEFAULT_PASSWORD,
|
|
38
|
+
persona_slot: int | None = None,
|
|
39
|
+
db_slot: int | None = None,
|
|
40
|
+
name_generator: Callable[[], str] | None = None,
|
|
41
|
+
):
|
|
42
|
+
super().__init__(
|
|
43
|
+
account_id=account_id,
|
|
44
|
+
password=password,
|
|
45
|
+
persona_slot=persona_slot,
|
|
46
|
+
db_slot=db_slot,
|
|
47
|
+
name_generator=name_generator,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# generate unique container name if not provided
|
|
51
|
+
self.container_name = container_name or f"{CONTAINER_PREFIX}_{uuid.uuid4().hex}"
|
|
52
|
+
self.image_name = image_name
|
|
53
|
+
self.use_tty = use_tty
|
|
54
|
+
|
|
55
|
+
self.command = self.build_command()
|
|
56
|
+
|
|
57
|
+
def build_command(self) -> list[str]:
|
|
58
|
+
return [
|
|
59
|
+
"docker",
|
|
60
|
+
"run",
|
|
61
|
+
"--name",
|
|
62
|
+
self.container_name,
|
|
63
|
+
"--init",
|
|
64
|
+
"--rm",
|
|
65
|
+
"-it" if self.use_tty else "-i",
|
|
66
|
+
"--ipc=private",
|
|
67
|
+
"--shm-size=100mb",
|
|
68
|
+
"-e",
|
|
69
|
+
f"L0={self.account_id}",
|
|
70
|
+
"-e",
|
|
71
|
+
f"L1={self.password}",
|
|
72
|
+
self.image_name,
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
def cleanup_container(self) -> None:
|
|
76
|
+
"""Remove any existing container with our name to avoid conflicts.
|
|
77
|
+
|
|
78
|
+
This handles the case where a previous container wasn't properly cleaned up,
|
|
79
|
+
e.g., if the process was killed abruptly or marimo re-ran a cell.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
subprocess.run(
|
|
83
|
+
["docker", "rm", "-f", self.container_name],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
check=False, # Don't raise if container doesn't exist
|
|
86
|
+
)
|
|
87
|
+
logger.debug("docker.container.cleanup", container_name=self.container_name)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.debug("docker.container.cleanup.failed", container_name=self.container_name, error=str(e))
|
|
90
|
+
|
|
91
|
+
def spawn(self) -> pexpect.spawn:
|
|
92
|
+
"""Spawn the Docker container, ensuring any stale container is cleaned up first."""
|
|
93
|
+
ensure_docker_image(self.image_name)
|
|
94
|
+
self.cleanup_container()
|
|
95
|
+
return super().spawn()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Handles persona selection and creation logic.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from faker import Faker
|
|
8
|
+
|
|
9
|
+
from mudgym.featurizers.strings import decode_text_bytes
|
|
10
|
+
from mudgym.logs import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
faker = Faker()
|
|
15
|
+
|
|
16
|
+
# the game rejects persona names longer than this or containing anything non-alphabetic
|
|
17
|
+
# ("Use names of 10 characters at most, please")
|
|
18
|
+
PERSONA_NAME_MAX_LENGTH = 10
|
|
19
|
+
|
|
20
|
+
PERSONA_NAME_BLACKLIST = [
|
|
21
|
+
"richard",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_persona_screen(text: bytes) -> dict[int, str]:
|
|
26
|
+
"""Extract persona names from persona selection text."""
|
|
27
|
+
text_str = decode_text_bytes(text)
|
|
28
|
+
# names are only ever max 10 characters and dont include punctuation, whitespace or non alpha chars
|
|
29
|
+
# TODO: tighten up this regex to only include valid persona names and make it operate on bytestrings
|
|
30
|
+
pattern = r"\((\d+)\)\s+([A-Za-z][\w'-]{0,9}|\*\*Unused\*\*)(?:,|\.|$)"
|
|
31
|
+
matches = re.findall(pattern, text_str)
|
|
32
|
+
return {int(num): name for num, name in matches}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_persona_name() -> str:
|
|
36
|
+
# the game rejects names longer than PERSONA_NAME_MAX_LENGTH or containing anything
|
|
37
|
+
# non-alphabetic, and faker first names can be hyphenated or accented ("Anne-Marie",
|
|
38
|
+
# "Renée"), which would wedge persona creation at the name prompt
|
|
39
|
+
name = ""
|
|
40
|
+
while (
|
|
41
|
+
not name.isascii()
|
|
42
|
+
or not name.isalpha()
|
|
43
|
+
or len(name) > PERSONA_NAME_MAX_LENGTH
|
|
44
|
+
or name.lower() in PERSONA_NAME_BLACKLIST
|
|
45
|
+
):
|
|
46
|
+
name = faker.first_name()
|
|
47
|
+
return name
|