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.
Files changed (58) hide show
  1. mudgym/__init__.py +24 -0
  2. mudgym/actions.py +11 -0
  3. mudgym/connections/__init__.py +0 -0
  4. mudgym/connections/config.py +17 -0
  5. mudgym/connections/connection.py +150 -0
  6. mudgym/connections/docker_exec.py +152 -0
  7. mudgym/connections/docker_image.py +98 -0
  8. mudgym/connections/docker_run.py +95 -0
  9. mudgym/connections/persona.py +47 -0
  10. mudgym/connections/prompts.py +298 -0
  11. mudgym/connections/provider.py +250 -0
  12. mudgym/connections/recording.py +218 -0
  13. mudgym/connections/registry.py +81 -0
  14. mudgym/connections/state_machine.py +533 -0
  15. mudgym/connections/termination.py +47 -0
  16. mudgym/connections/transitions.py +234 -0
  17. mudgym/db/directions.py +38 -0
  18. mudgym/db/index.py +122 -0
  19. mudgym/db/levels.py +68 -0
  20. mudgym/db/rooms.py +1156 -0
  21. mudgym/db/weather.py +15 -0
  22. mudgym/envs/__init__.py +0 -0
  23. mudgym/envs/actions/__init__.py +0 -0
  24. mudgym/envs/actions/discrete.py +53 -0
  25. mudgym/envs/env.py +375 -0
  26. mudgym/envs/factory.py +273 -0
  27. mudgym/envs/fields/__init__.py +31 -0
  28. mudgym/envs/fields/feinventory.py +75 -0
  29. mudgym/envs/fields/fescore.py +140 -0
  30. mudgym/envs/fields/fexits.py +92 -0
  31. mudgym/envs/fields/field.py +126 -0
  32. mudgym/envs/fields/mgcheats.py +113 -0
  33. mudgym/envs/fields/rawbytes.py +52 -0
  34. mudgym/envs/fields/superquicklook.py +244 -0
  35. mudgym/envs/registration.py +30 -0
  36. mudgym/envs/specs.py +56 -0
  37. mudgym/envs/validation.py +30 -0
  38. mudgym/envs/vector.py +43 -0
  39. mudgym/envs/zoo.py +229 -0
  40. mudgym/featurizers/ansi.py +25 -0
  41. mudgym/featurizers/persona_names.py +32 -0
  42. mudgym/featurizers/points.py +57 -0
  43. mudgym/featurizers/quickscore.py +44 -0
  44. mudgym/featurizers/responses.py +114 -0
  45. mudgym/featurizers/strings.py +58 -0
  46. mudgym/logs.py +273 -0
  47. mudgym/notebooks/__init__.py +39 -0
  48. mudgym/notebooks/frames.py +378 -0
  49. mudgym/notebooks/panels.py +144 -0
  50. mudgym/notebooks/room_map.py +391 -0
  51. mudgym/notebooks/style.py +65 -0
  52. mudgym/notebooks/tables.py +177 -0
  53. mudgym/session.py +247 -0
  54. mudgym-0.3.0.dist-info/METADATA +69 -0
  55. mudgym-0.3.0.dist-info/RECORD +58 -0
  56. mudgym-0.3.0.dist-info/WHEEL +4 -0
  57. mudgym-0.3.0.dist-info/licenses/LICENSE +21 -0
  58. 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