mudgym 0.3.0b1__tar.gz

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.
Files changed (50) hide show
  1. mudgym-0.3.0b1/LICENSE +21 -0
  2. mudgym-0.3.0b1/NOTICE +13 -0
  3. mudgym-0.3.0b1/PKG-INFO +69 -0
  4. mudgym-0.3.0b1/README.md +35 -0
  5. mudgym-0.3.0b1/pyproject.toml +98 -0
  6. mudgym-0.3.0b1/src/mudgym/__init__.py +24 -0
  7. mudgym-0.3.0b1/src/mudgym/actions.py +11 -0
  8. mudgym-0.3.0b1/src/mudgym/connections/__init__.py +0 -0
  9. mudgym-0.3.0b1/src/mudgym/connections/config.py +17 -0
  10. mudgym-0.3.0b1/src/mudgym/connections/connection.py +150 -0
  11. mudgym-0.3.0b1/src/mudgym/connections/docker_exec.py +152 -0
  12. mudgym-0.3.0b1/src/mudgym/connections/docker_image.py +98 -0
  13. mudgym-0.3.0b1/src/mudgym/connections/docker_run.py +95 -0
  14. mudgym-0.3.0b1/src/mudgym/connections/persona.py +47 -0
  15. mudgym-0.3.0b1/src/mudgym/connections/prompts.py +261 -0
  16. mudgym-0.3.0b1/src/mudgym/connections/provider.py +250 -0
  17. mudgym-0.3.0b1/src/mudgym/connections/registry.py +75 -0
  18. mudgym-0.3.0b1/src/mudgym/connections/state_machine.py +527 -0
  19. mudgym-0.3.0b1/src/mudgym/connections/termination.py +47 -0
  20. mudgym-0.3.0b1/src/mudgym/connections/transitions.py +234 -0
  21. mudgym-0.3.0b1/src/mudgym/db/directions.py +38 -0
  22. mudgym-0.3.0b1/src/mudgym/db/index.py +122 -0
  23. mudgym-0.3.0b1/src/mudgym/db/levels.py +66 -0
  24. mudgym-0.3.0b1/src/mudgym/db/rooms.py +1156 -0
  25. mudgym-0.3.0b1/src/mudgym/db/weather.py +15 -0
  26. mudgym-0.3.0b1/src/mudgym/envs/__init__.py +0 -0
  27. mudgym-0.3.0b1/src/mudgym/envs/actions/__init__.py +0 -0
  28. mudgym-0.3.0b1/src/mudgym/envs/actions/discrete.py +53 -0
  29. mudgym-0.3.0b1/src/mudgym/envs/env.py +359 -0
  30. mudgym-0.3.0b1/src/mudgym/envs/factory.py +256 -0
  31. mudgym-0.3.0b1/src/mudgym/envs/fields/__init__.py +31 -0
  32. mudgym-0.3.0b1/src/mudgym/envs/fields/feinventory.py +75 -0
  33. mudgym-0.3.0b1/src/mudgym/envs/fields/fescore.py +140 -0
  34. mudgym-0.3.0b1/src/mudgym/envs/fields/fexits.py +92 -0
  35. mudgym-0.3.0b1/src/mudgym/envs/fields/field.py +122 -0
  36. mudgym-0.3.0b1/src/mudgym/envs/fields/mgcheats.py +118 -0
  37. mudgym-0.3.0b1/src/mudgym/envs/fields/rawbytes.py +52 -0
  38. mudgym-0.3.0b1/src/mudgym/envs/fields/superquicklook.py +239 -0
  39. mudgym-0.3.0b1/src/mudgym/envs/registration.py +30 -0
  40. mudgym-0.3.0b1/src/mudgym/envs/specs.py +56 -0
  41. mudgym-0.3.0b1/src/mudgym/envs/validation.py +30 -0
  42. mudgym-0.3.0b1/src/mudgym/envs/vector.py +43 -0
  43. mudgym-0.3.0b1/src/mudgym/envs/zoo.py +229 -0
  44. mudgym-0.3.0b1/src/mudgym/featurizers/ansi.py +25 -0
  45. mudgym-0.3.0b1/src/mudgym/featurizers/points.py +57 -0
  46. mudgym-0.3.0b1/src/mudgym/featurizers/responses.py +114 -0
  47. mudgym-0.3.0b1/src/mudgym/featurizers/strings.py +58 -0
  48. mudgym-0.3.0b1/src/mudgym/logs.py +273 -0
  49. mudgym-0.3.0b1/src/mudgym/notebooks.py +260 -0
  50. mudgym-0.3.0b1/src/mudgym/session.py +234 -0
mudgym-0.3.0b1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rolo Mawlabaux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mudgym-0.3.0b1/NOTICE ADDED
@@ -0,0 +1,13 @@
1
+ MudGym
2
+ Copyright (c) 2026 Rolo Mawlabaux
3
+
4
+ The Python code and tooling are made available under the MIT License (see
5
+ LICENSE).
6
+
7
+ Permission to use the MUD2 game for research purposes has been provided by
8
+ Richard Bartle, with kind thanks. The MUD2 game, name, source code, and
9
+ associated story remain the intellectual property of Richard Bartle and
10
+ Roy Trubshaw and may not be used for commercial purposes.
11
+
12
+ MudGym runs the MUD2 game inside the ghcr.io/rolo/mudgym Docker image; the
13
+ image is pulled at runtime and is not part of this distribution.
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: mudgym
3
+ Version: 0.3.0b1
4
+ Summary: Reinforcement learning environment for MUD2.
5
+ Keywords: reinforcement-learning,gymnasium,pettingzoo,multi-agent,mud,mud2,text-adventure,llm-agents
6
+ Author: Rolo Mawlabaux
7
+ Author-email: Rolo Mawlabaux <rolo@kingrolo.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ License-File: NOTICE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Operating System :: POSIX
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Games/Entertainment :: Multi-User Dungeons (MUD)
21
+ Requires-Dist: ansi2html>=1.9.2
22
+ Requires-Dist: faker>=37.12.0
23
+ Requires-Dist: gymnasium>=1.2.2
24
+ Requires-Dist: pexpect>=4.9.0
25
+ Requires-Dist: structlog>=25.5.0
26
+ Requires-Dist: numpy>=2.3.4
27
+ Requires-Dist: pettingzoo>=1.25.0
28
+ Requires-Python: >=3.13
29
+ Project-URL: Documentation, https://rolo.github.io/mudgym/
30
+ Project-URL: Homepage, https://rolo.github.io/mudgym/
31
+ Project-URL: Issues, https://github.com/rolo/mudgym/issues
32
+ Project-URL: Repository, https://github.com/rolo/mudgym
33
+ Description-Content-Type: text/markdown
34
+
35
+ # MudGym
36
+
37
+ A Gymnasium reinforcement learning environment for MUD2. One of the first online multiplayer text adventure games.
38
+
39
+ ## Setup
40
+
41
+ A Docker engine is required to run the game. The image `ghcr.io/rolo/mudgym` is pulled automatically on first use.
42
+
43
+ Install MudGym from PyPI:
44
+
45
+ ```bash
46
+ uv add mudgym
47
+ ```
48
+
49
+ ## Quickstart
50
+
51
+ ```python
52
+ from mudgym import make_env
53
+
54
+ env = make_env(observation="parsed")
55
+ observation, info = env.reset()
56
+ observation, reward, terminated, truncated, info = env.step("howl")
57
+ print(observation["room_name"], reward)
58
+ env.close()
59
+ ```
60
+
61
+ ## Docs
62
+
63
+ See the [docs](https://rolo.github.io/mudgym/) for more examples.
64
+
65
+ ## License
66
+
67
+ The Python code and tooling in this repository are made available under the MIT License.
68
+
69
+ Permission to use the MUD2 game for research purposes has been provided by Richard Bartle, with kind thanks. The MUD2 game, name, source code, and associated story remain the intellectual property of Richard Bartle and Roy Trubshaw and may not be used for commercial purposes.
@@ -0,0 +1,35 @@
1
+ # MudGym
2
+
3
+ A Gymnasium reinforcement learning environment for MUD2. One of the first online multiplayer text adventure games.
4
+
5
+ ## Setup
6
+
7
+ A Docker engine is required to run the game. The image `ghcr.io/rolo/mudgym` is pulled automatically on first use.
8
+
9
+ Install MudGym from PyPI:
10
+
11
+ ```bash
12
+ uv add mudgym
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ from mudgym import make_env
19
+
20
+ env = make_env(observation="parsed")
21
+ observation, info = env.reset()
22
+ observation, reward, terminated, truncated, info = env.step("howl")
23
+ print(observation["room_name"], reward)
24
+ env.close()
25
+ ```
26
+
27
+ ## Docs
28
+
29
+ See the [docs](https://rolo.github.io/mudgym/) for more examples.
30
+
31
+ ## License
32
+
33
+ The Python code and tooling in this repository are made available under the MIT License.
34
+
35
+ Permission to use the MUD2 game for research purposes has been provided by Richard Bartle, with kind thanks. The MUD2 game, name, source code, and associated story remain the intellectual property of Richard Bartle and Roy Trubshaw and may not be used for commercial purposes.
@@ -0,0 +1,98 @@
1
+ [project]
2
+ name = "mudgym"
3
+ version = "0.3.0b1"
4
+ description = "Reinforcement learning environment for MUD2."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE", "NOTICE"]
8
+ authors = [
9
+ { name = "Rolo Mawlabaux", email = "rolo@kingrolo.com" }
10
+ ]
11
+ requires-python = ">=3.13"
12
+ keywords = [
13
+ "reinforcement-learning",
14
+ "gymnasium",
15
+ "pettingzoo",
16
+ "multi-agent",
17
+ "mud",
18
+ "mud2",
19
+ "text-adventure",
20
+ "llm-agents",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 4 - Beta",
24
+ "Intended Audience :: Science/Research",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ # pexpect is POSIX-only, so the game connection does not work on Windows.
31
+ "Operating System :: POSIX",
32
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
33
+ "Topic :: Games/Entertainment :: Multi-User Dungeons (MUD)",
34
+ ]
35
+ dependencies = [
36
+ "ansi2html>=1.9.2",
37
+ "faker>=37.12.0",
38
+ "gymnasium>=1.2.2",
39
+ "pexpect>=4.9.0",
40
+ "structlog>=25.5.0",
41
+ "numpy>=2.3.4",
42
+ "pettingzoo>=1.25.0",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://rolo.github.io/mudgym/"
47
+ Documentation = "https://rolo.github.io/mudgym/"
48
+ Repository = "https://github.com/rolo/mudgym"
49
+ Issues = "https://github.com/rolo/mudgym/issues"
50
+
51
+ [dependency-groups]
52
+ docs = [
53
+ "zensical>=0.0.44",
54
+ "mkdocstrings-python>=2.0.4",
55
+ ]
56
+ dev = [
57
+ "ruff>=0.14.3",
58
+ "pytest>=8.0.0",
59
+ "pytest-cov>=6.0.0",
60
+ "pytest-subtests>=0.15.0",
61
+ "ipdb>=0.13.13",
62
+ "tabulate>=0.10.0",
63
+ {include-group = "docs"},
64
+ ]
65
+
66
+ [build-system]
67
+ requires = ["uv_build>=0.9.7,<0.10.0"]
68
+ build-backend = "uv_build"
69
+
70
+ [tool.uv.build-backend]
71
+ # leave test modules out of the published artifacts
72
+ source-exclude = ["**/tests", "**/tests/**"]
73
+ wheel-exclude = ["**/tests", "**/tests/**"]
74
+
75
+ [tool.ruff]
76
+ line-length = 120
77
+
78
+ [tool.ruff.lint]
79
+ extend-select = ["PLC0415", "I", "UP", "SIM", "C4", "PIE", "RET"]
80
+ # assertions read better as expected-then-actual, which SIM300 calls a Yoda condition
81
+ ignore = ["SIM300"]
82
+
83
+ [tool.coverage.run]
84
+ branch = true
85
+ source = ["src"]
86
+ relative_files = true
87
+ omit = [
88
+ "*/tests/*",
89
+ ]
90
+
91
+ [tool.pytest.ini_options]
92
+ testpaths = ["tests", "src/mudgym"]
93
+ log_cli_format = "%(asctime)s %(levelname)-7s %(message)s"
94
+ log_cli_date_format = "%H:%M:%S"
95
+ markers = [
96
+ "soak: long-running live-game soak tests; excluded by default, run with -m soak",
97
+ ]
98
+ addopts = "-m 'not soak'"
@@ -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
+ ]
@@ -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.1")
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