Skip to content

API Reference

Factories

mudgym.envs.factory.make_env(observation='parsed', field_parsers=None, actions='text', render_mode=None, connection=None, connection_kwargs=None, tearoom_commands=None)

Build one Gymnasium environment.

Source code in src/mudgym/envs/factory.py
def make_env(
    observation: str = "parsed",
    field_parsers: Sequence[FieldSpec] | None = None,
    actions: str = "text",
    render_mode: str | None = None,
    connection: str | type[MudConnection] | Callable[..., MudConnection] | MudConnection | None = None,
    connection_kwargs: Mapping[str, Any] | None = None,
    tearoom_commands: str | None = None,
) -> gym.Env:
    """Build one Gymnasium environment."""
    if actions not in {"text", "directions"}:
        raise ValueError(f"actions must be one of: 'text', 'directions' (got {actions!r})")
    if isinstance(connection, MudConnection) and connection_kwargs:
        raise ValueError("connection_kwargs is not valid when passing an explicit connection instance.")
    fields = _resolve_field_parsers(observation, field_parsers)

    connection_factory = registry.default_connection if connection is None else connection
    if isinstance(connection_factory, str):
        connection_factory = registry.connections[connection_factory]
    resolved_connection = (
        connection_factory
        if isinstance(connection_factory, MudConnection)
        else connection_factory(**dict(connection_kwargs or {}))
    )

    try:
        # MudEnv owns the connection as soon as construction succeeds. Until then it is still ours to close if field validation or session setup fails.
        env: gym.Env = MudEnv(
            connection=resolved_connection,
            field_parsers=fields,
            render_mode=render_mode,
            tearoom_commands=tearoom_commands,
        )
    except BaseException:
        close_quietly(resolved_connection)
        raise
    if actions == "text":
        return env

    try:
        return DiscreteDirectionsWrapper(env)
    except BaseException:
        close_quietly(env)
        raise

mudgym.envs.factory.make_vector_env(envs, *, observation='parsed', field_parsers=None, actions='text', render_mode=None, tearoom_commands=None, provider=None)

Create a Gymnasium vector env. It need not know how worlds are arranged.

The provider decides where the connections lead. Reset is deliberately all-or-nothing for now, and both reset and step finish the shared action/setup work before collecting observations. A supplied provider becomes the resulting environment's responsibility and is closed with it.

Source code in src/mudgym/envs/factory.py
def make_vector_env(
    envs: int,
    *,
    observation: str = "parsed",
    field_parsers: Sequence[FieldSpec] | None = None,
    actions: str = "text",
    render_mode: str | None = None,
    tearoom_commands: str | None = None,
    provider: ConnectionProvider | None = None,
) -> VectorEnv:
    """Create a Gymnasium vector env. It need not know how worlds are arranged.

    The provider decides where the connections lead. Reset is deliberately all-or-nothing for now, and both reset and step finish the shared action/setup work before collecting observations. A supplied provider becomes the resulting environment's responsibility and is closed with it.
    """
    if envs < 1:
        raise ValueError("envs must be at least 1.")
    if actions not in {"text", "directions"}:
        raise ValueError(f"actions must be one of: 'text', 'directions' (got {actions!r})")
    fields = _resolve_field_parsers(observation, field_parsers)

    if provider is None:
        provider = registry.default_provider_factory()
    connections: list[MudConnection] = []
    children: list[MudEnv] = []

    try:
        # The provider returns the whole batch in one go so it can size any shared resources. We still check the length here: a custom provider should fail loudly rather than create a vector whose actual shape disagrees with its public shape.
        connections = provider.create_connections(envs)
        if len(connections) != envs:
            raise RuntimeError(f"Provider returned {len(connections)} connections, expected {envs}.")

        for connection in connections:
            children.append(
                MudEnv(
                    connection=connection,
                    field_parsers=fields,
                    render_mode=render_mode,
                    tearoom_commands=tearoom_commands,
                )
            )

        base_env = MudVectorEnv(children, provider=provider)
        if actions == "directions":
            return VectorDiscreteDirectionsWrapper(base_env)
        return base_env
    except BaseException:
        # Every successful child owns its matching connection. Anything after that prefix never made it into a MudEnv and still needs closing directly; the provider owns the resources underneath both groups. Cleanup errors are secondary to the construction failure.
        close_quietly(*children, *connections[len(children) :], provider)
        raise

mudgym.envs.factory.make_parallel_env(agents=2, *, observation='parsed', field_parsers=None, actions='text', render_mode=None, tearoom_commands=None, provider=None)

Create a PettingZoo environment whose players share one MUD world.

The registry supplies a one-world default. If a caller passes a provider we trust that it honours the same promise. The resulting environment owns that provider, and action wrappers sit around the joint environment rather than around each player.

Source code in src/mudgym/envs/factory.py
def make_parallel_env(
    agents: int = 2,
    *,
    observation: str = "parsed",
    field_parsers: Sequence[FieldSpec] | None = None,
    actions: str = "text",
    render_mode: str | None = None,
    tearoom_commands: str | None = None,
    provider: ConnectionProvider | None = None,
) -> ParallelEnv:
    """Create a PettingZoo environment whose players share one MUD world.

    The registry supplies a one-world default. If a caller passes a provider we trust that it honours the same promise.
    The resulting environment owns that provider, and action wrappers sit around the joint environment rather than around each player.
    """
    if agents < 1:
        raise ValueError("agents must be at least 1.")
    if actions not in {"text", "directions"}:
        raise ValueError(f"actions must be one of: 'text', 'directions' (got {actions!r})")
    fields = _resolve_field_parsers(observation, field_parsers)
    child_render_mode = "ansi" if render_mode is not None else None

    if provider is None:
        provider = registry.default_parallel_provider_factory()
    connections: list[MudConnection] = []
    children: dict[str, MudEnv] = {}

    try:
        # As with the vector factory, batch size is worth checking even though the protocol promises it.
        connections = provider.create_connections(agents)
        if len(connections) != agents:
            raise RuntimeError(f"Provider returned {len(connections)} connections, expected {agents}.")

        for index, connection in enumerate(connections):
            children[f"player_{index}"] = MudEnv(
                connection=connection,
                field_parsers=fields,
                render_mode=child_render_mode,
                tearoom_commands=tearoom_commands,
            )

        base_env = MudParallelEnv(children, provider=provider, render_mode=render_mode)
        if actions == "directions":
            return ParallelDiscreteDirectionsWrapper(base_env)
        return base_env
    except BaseException:
        # Dict insertion order follows the connection batch. Successful children own the prefix;
        # the remaining connections were allocated but never made it into a MudEnv.
        close_quietly(*children.values(), *connections[len(children) :], provider)
        raise

Environments

mudgym.envs.env.MudEnv

Bases: Env[dict[str, Any], str]

A Gymnasium environment for MUD2.

Source code in src/mudgym/envs/env.py
class MudEnv(gym.Env[dict[str, Any], str]):
    """
    A Gymnasium environment for MUD2.
    """

    metadata = {
        "render_modes": ["human", "ansi"],
    }

    def __init__(
        self,
        *,
        field_parsers: Sequence[FieldSpec] | None = None,
        tearoom_commands: str | None = None,
        connection: MudConnection,
        render_mode: str | None = None,
    ):
        super().__init__()

        self.action_space = gym.spaces.Text(
            max_length=ACTION_MAX_LENGTH,
            min_length=1,
            charset=ACTION_CHARSET,
        )

        # None means use default. An explicit empty sequence means empty but it will fail the command check below as we
        # need an end of step marker
        if field_parsers is None:
            field_parsers = DEFAULT_FIELDS
        self.fields = [instantiate_field(field) for field in field_parsers]

        observation_space: dict[str, gym.spaces.Space] = {
            "text": gym.spaces.Text(max_length=TEXT_MAX_LENGTH, min_length=0, charset=TEXT_CHARSET),
        }
        empty_observation: dict[str, Any] = {"text": ""}
        for field in self.fields:
            field_space = field.space()
            duplicates = observation_space.keys() & field_space.keys()
            if duplicates:
                raise ValueError(f"Duplicate observation keys: {sorted(duplicates)}")
            observation_space.update(field_space)
            empty_observation.update(field.empty())

        self.observation_space = gym.spaces.Dict(observation_space)
        self.empty_observation = empty_observation

        command_fields = tuple(field for field in self.fields if field.command is not None)
        commands = tuple(field.command for field in command_fields)
        if not command_fields:
            raise ValueError("At least one observation field must declare a command.")

        final_field = command_fields[-1]
        if final_field.end_of_turn_marker is None:
            raise ValueError(
                "The final commanded observation field must declare an end_of_turn_marker (fei, fes, mgcheats, ...)."
            )

        self.observation_command_fields: tuple[ObservationField, ...] = command_fields
        observation_line = ",".join(commands)

        self.tearoom_commands = tearoom_commands
        self.render_mode = render_mode
        self.last_render_bytes: bytes = b""
        self.step_count = 0

        # keep score independently of any one observation response
        self.points: int | None = None

        self.session = MudSession(
            connection=connection,
            observation_line=observation_line,
            end_of_turn_marker=final_field.end_of_turn_marker,
        )

    @property
    def persona(self) -> str | None:
        """The persona name of the current player. Set during session reset."""
        return self.session.persona

    def bytes_to_observation(
        self,
        raw_bytes: bytes,
        *,
        sent_lines: Sequence[str],
        response_complete: bool,
    ) -> tuple[dict[str, Any], bytes, dict[str, bytes]]:
        """Turn a step's response payload into an observation and its renderable bytes."""

        # deepcopy so we don't accidentally mutate
        obs = deepcopy(self.empty_observation)

        # split the response into pre and post echo
        segments = split_on_echo_lines(raw_bytes, sent_lines)
        if segments is not None:
            # anything that came from the game before our echo we don't try and parse into observation fields
            pre_echo_chunks = [chunk for segment in segments[:-1] for chunk in split_on_prompt(segment)]
            chunks = split_on_prompt(segments[-1])
        else:
            pre_echo_chunks = []
            chunks = split_on_prompt(raw_bytes)

        # fields with no command set use the whole step's bytes.
        for field in (field for field in self.fields if field.command is None):
            obs.update(field.extract([raw_bytes], persona=self.persona))

        payload_text_chunks: list[bytes] = []
        field_refusals: dict[str, bytes] = {}
        if response_complete:
            # claim in the same order commands were sent. A refusal still consumes, eg, asleep
            pending_fields = list(self.observation_command_fields)
            for position, chunk in enumerate(chunks):
                field = pending_fields[0] if pending_fields else None
                if field is not None and field.is_refusal(chunk):
                    field_refusals[field.__class__.__name__] = chunk
                    payload_text_chunks.append(chunk)
                    pending_fields.pop(0)
                elif field is not None and field.matches(chunk):
                    obs.update(field.extract([chunk], persona=self.persona))
                    if not field.remove_on_match:
                        payload_text_chunks.append(chunk)
                    pending_fields.pop(0)
                elif position == len(chunks) - 1:
                    # final chunk can be used just as a marker rather than a field
                    pass
                else:
                    payload_text_chunks.append(chunk)
            if pending_fields:
                raise RuntimeError(
                    f"end of step marker arrived but fields {[f.__class__.__name__ for f in pending_fields]} "
                    f"found no matching response among {len(chunks)} window chunks"
                )

        text_chunks = [*pre_echo_chunks, *payload_text_chunks]
        if not response_complete:
            # Without the marker we cannot safely line chunks up with fields. Preserve the bytes as text rather than pretending the structured observation is complete.
            text_chunks.extend(chunks)

        # keeps the game's ANSI colour - text observation space doesn't.
        render_bytes = normalise_lines(b"\n".join(text_chunks))
        text = decode_text_bytes(strip_ansi(render_bytes))

        if len(text) > TEXT_MAX_LENGTH:
            logger.warning(f"text length {len(text)} exceeds TEXT_MAX_LENGTH {TEXT_MAX_LENGTH}, truncating")

        obs["text"] = text[:TEXT_MAX_LENGTH]
        return obs, render_bytes, field_refusals

    def clean_tearoom_exit(self, raw_bytes: bytes, sent_lines: Sequence[str]) -> tuple[bytes, int]:
        """Remove the tearoom setup and return the exit fes score."""
        if len(sent_lines) != 2:
            raise RuntimeError(f"tearoom exit sent {len(sent_lines)} wire lines, expected 2")

        action_echo = echo_pattern(sent_lines[0]).search(raw_bytes)
        observation_echo = echo_pattern(sent_lines[1]).search(
            raw_bytes,
            action_echo.end() if action_echo is not None else 0,
        )
        if action_echo is None or observation_echo is None:
            raise RuntimeError(f"tearoom exit echoes not found in: {raw_bytes!r}")

        action_response = raw_bytes[action_echo.end() : observation_echo.start()]
        prompts = tuple(RESPONSE_PROMPT_RE.finditer(action_response))
        if not prompts:
            raise RuntimeError(f"tearoom exit fes prompt not found in: {action_response!r}")

        fes_prompt = prompts[0]
        fes_response = action_response[: fes_prompt.start()]
        move_response = action_response[fes_prompt.end() :]
        field = FEScoreField(include_keys=())
        if not field.matches(fes_response):
            raise RuntimeError("reset completed without establishing the persona score")

        trim_re = re.compile(rb"\.\.\.\r?\n")
        trim_match = trim_re.search(move_response)
        if trim_match is None:
            raise ValueError(f"tearoom exit marker {trim_re.pattern!r} not found in: {move_response!r}")

        cleaned_bytes = (
            raw_bytes[: action_echo.end()] + move_response[trim_match.end() :] + raw_bytes[observation_echo.start() :]
        )
        values = field.full_extract([fes_response], persona=self.persona)
        return cleaned_bytes, int(values["points"])

    def make_info(
        self,
        *,
        raw_bytes: bytes,
        render_bytes: bytes,
        rejected: bool,
        field_refusals: dict[str, bytes],
    ) -> dict[str, Any]:
        info: dict[str, Any] = {
            "raw_bytes": raw_bytes,
            "render_bytes": render_bytes,
            "step": self.step_count,
            "persona": self.persona,
            "action_rejected": rejected,
        }
        if field_refusals:
            info["field_refusals"] = field_refusals
        return info

    def render(self) -> str | None:
        if self.render_mode is None:
            return None
        cleaned_text = decode_text_bytes(self.last_render_bytes)
        if self.render_mode == "human":
            print(cleaned_text, end="", flush=True)
            return None
        return cleaned_text

    def reset(
        self,
        *,
        seed: int | None = None,
        options: dict | None = None,
    ) -> tuple[dict[str, Any], dict[str, Any]]:
        super().reset(seed=seed, options=options)

        if seed is not None:
            self.action_space.seed(seed)

        self.step_count = 0

        # reset the session which takes us to the tearoom and sets the persona name via quickscore
        self.session.reset()

        # tearoom commands are episode setup, issued before the exit step
        if self.tearoom_commands:
            raw_bytes, terminated, truncated, _ = self.session.command(self.tearoom_commands)
            if terminated or truncated:
                raise RuntimeError(
                    f"tearoom commands {self.tearoom_commands!r} failed during reset "
                    f"(terminated={terminated}, truncated={truncated}); raw_bytes={raw_bytes!r}"
                )

        # step out of the tearoom and into The Land
        command = "fes,move north"
        raw_bytes, terminated, truncated, debug_info = self.session.command(command)

        if terminated or truncated:
            raise RuntimeError(
                f"step out of the tearoom failed during reset "
                f"(terminated={terminated}, truncated={truncated}); "
                f"bytes_length={len(raw_bytes)}; "
                f"raw_bytes={raw_bytes!r}; "
                f"debug_info={debug_info!r}"
            )

        raw_bytes, self.points = self.clean_tearoom_exit(raw_bytes, debug_info["sent_lines"])

        obs, render_bytes, field_refusals = self.bytes_to_observation(
            raw_bytes,
            sent_lines=debug_info["sent_lines"],
            response_complete=bool(debug_info.get("marker_arrived", False)),
        )
        self.last_render_bytes = render_bytes
        info = self.make_info(
            raw_bytes=raw_bytes,
            render_bytes=render_bytes,
            rejected=bool(debug_info.get("rejected", False)),
            field_refusals=field_refusals,
        )

        if self.render_mode == "human":
            self.render()

        return obs, info

    def step(
        self,
        action: str,
    ) -> tuple[dict[str, Any], float, bool, bool, dict[str, Any]]:
        """Send one action, then receive its marker-framed observation.

        Vector and parallel coordinators call ``act()`` on every child before calling ``observe()``
        on any child, so each returned observation follows the complete joint action batch.
        """
        self.act(action)
        return self.observe()

    def act(self, action: str) -> None:
        """Send an action now, leaving its observation for a later ``observe`` call."""
        if not self.action_space.contains(action):
            raise ValueError(f"Invalid action {action!r}; expected {self.action_space}.")
        if self.points is None:
            raise RuntimeError("step called before reset established the persona score")
        self.session.send(action)
        self.step_count += 1

    def observe(self) -> tuple[dict[str, Any], float, bool, bool, dict[str, Any]]:
        """Receive everything up to this player's end-of-turn marker.

        This includes the earlier action, the observation-command responses, and anything caused by other players since that action was sent.
        """
        raw_bytes, terminated, truncated, debug_info = self.session.receive()

        # A truncated result means the read window ended without its marker: we left the game, the transport died, timed out, or otherwise stopped for a reason outside the MDP.
        obs, render_bytes, field_refusals = self.bytes_to_observation(
            raw_bytes,
            sent_lines=debug_info["sent_lines"],
            response_complete=bool(debug_info.get("marker_arrived", False)) and not truncated,
        )
        self.last_render_bytes = render_bytes
        info = self.make_info(
            raw_bytes=raw_bytes,
            render_bytes=render_bytes,
            rejected=bool(debug_info.get("rejected", False)),
            field_refusals=field_refusals,
        )

        # points events require colours a player cannot forge through the command echo
        points_before_step = self.points
        if points_before_step is None:
            raise RuntimeError("step called before reset established the persona score")

        points_changes = parse_points_changes(raw_bytes)
        event_points = points_changes["points"]
        # if we end up with points > WIZARD_POINTS, we terminate the episode.
        if event_points is not None and points_changes["delta"] and event_points >= WIZARD_POINTS:
            terminated = True

        # permadeath is a final points event at zero, after any numeric event
        if terminated and is_permadeath(raw_bytes):
            event_points = 0
        if event_points is not None:
            info["points"] = event_points
            self.points = event_points

        reward = float(self.points - points_before_step)

        # report tracked points even when the FES response is stale or missing
        if "points" in obs:
            obs["points"] = INT_DTYPE(self.points)

        if self.render_mode == "human":
            self.render()

        return obs, reward, terminated, truncated, info

    def close(self) -> None:
        super().close()
        self.session.close()

persona property

The persona name of the current player. Set during session reset.

bytes_to_observation(raw_bytes, *, sent_lines, response_complete)

Turn a step's response payload into an observation and its renderable bytes.

Source code in src/mudgym/envs/env.py
def bytes_to_observation(
    self,
    raw_bytes: bytes,
    *,
    sent_lines: Sequence[str],
    response_complete: bool,
) -> tuple[dict[str, Any], bytes, dict[str, bytes]]:
    """Turn a step's response payload into an observation and its renderable bytes."""

    # deepcopy so we don't accidentally mutate
    obs = deepcopy(self.empty_observation)

    # split the response into pre and post echo
    segments = split_on_echo_lines(raw_bytes, sent_lines)
    if segments is not None:
        # anything that came from the game before our echo we don't try and parse into observation fields
        pre_echo_chunks = [chunk for segment in segments[:-1] for chunk in split_on_prompt(segment)]
        chunks = split_on_prompt(segments[-1])
    else:
        pre_echo_chunks = []
        chunks = split_on_prompt(raw_bytes)

    # fields with no command set use the whole step's bytes.
    for field in (field for field in self.fields if field.command is None):
        obs.update(field.extract([raw_bytes], persona=self.persona))

    payload_text_chunks: list[bytes] = []
    field_refusals: dict[str, bytes] = {}
    if response_complete:
        # claim in the same order commands were sent. A refusal still consumes, eg, asleep
        pending_fields = list(self.observation_command_fields)
        for position, chunk in enumerate(chunks):
            field = pending_fields[0] if pending_fields else None
            if field is not None and field.is_refusal(chunk):
                field_refusals[field.__class__.__name__] = chunk
                payload_text_chunks.append(chunk)
                pending_fields.pop(0)
            elif field is not None and field.matches(chunk):
                obs.update(field.extract([chunk], persona=self.persona))
                if not field.remove_on_match:
                    payload_text_chunks.append(chunk)
                pending_fields.pop(0)
            elif position == len(chunks) - 1:
                # final chunk can be used just as a marker rather than a field
                pass
            else:
                payload_text_chunks.append(chunk)
        if pending_fields:
            raise RuntimeError(
                f"end of step marker arrived but fields {[f.__class__.__name__ for f in pending_fields]} "
                f"found no matching response among {len(chunks)} window chunks"
            )

    text_chunks = [*pre_echo_chunks, *payload_text_chunks]
    if not response_complete:
        # Without the marker we cannot safely line chunks up with fields. Preserve the bytes as text rather than pretending the structured observation is complete.
        text_chunks.extend(chunks)

    # keeps the game's ANSI colour - text observation space doesn't.
    render_bytes = normalise_lines(b"\n".join(text_chunks))
    text = decode_text_bytes(strip_ansi(render_bytes))

    if len(text) > TEXT_MAX_LENGTH:
        logger.warning(f"text length {len(text)} exceeds TEXT_MAX_LENGTH {TEXT_MAX_LENGTH}, truncating")

    obs["text"] = text[:TEXT_MAX_LENGTH]
    return obs, render_bytes, field_refusals

clean_tearoom_exit(raw_bytes, sent_lines)

Remove the tearoom setup and return the exit fes score.

Source code in src/mudgym/envs/env.py
def clean_tearoom_exit(self, raw_bytes: bytes, sent_lines: Sequence[str]) -> tuple[bytes, int]:
    """Remove the tearoom setup and return the exit fes score."""
    if len(sent_lines) != 2:
        raise RuntimeError(f"tearoom exit sent {len(sent_lines)} wire lines, expected 2")

    action_echo = echo_pattern(sent_lines[0]).search(raw_bytes)
    observation_echo = echo_pattern(sent_lines[1]).search(
        raw_bytes,
        action_echo.end() if action_echo is not None else 0,
    )
    if action_echo is None or observation_echo is None:
        raise RuntimeError(f"tearoom exit echoes not found in: {raw_bytes!r}")

    action_response = raw_bytes[action_echo.end() : observation_echo.start()]
    prompts = tuple(RESPONSE_PROMPT_RE.finditer(action_response))
    if not prompts:
        raise RuntimeError(f"tearoom exit fes prompt not found in: {action_response!r}")

    fes_prompt = prompts[0]
    fes_response = action_response[: fes_prompt.start()]
    move_response = action_response[fes_prompt.end() :]
    field = FEScoreField(include_keys=())
    if not field.matches(fes_response):
        raise RuntimeError("reset completed without establishing the persona score")

    trim_re = re.compile(rb"\.\.\.\r?\n")
    trim_match = trim_re.search(move_response)
    if trim_match is None:
        raise ValueError(f"tearoom exit marker {trim_re.pattern!r} not found in: {move_response!r}")

    cleaned_bytes = (
        raw_bytes[: action_echo.end()] + move_response[trim_match.end() :] + raw_bytes[observation_echo.start() :]
    )
    values = field.full_extract([fes_response], persona=self.persona)
    return cleaned_bytes, int(values["points"])

step(action)

Send one action, then receive its marker-framed observation.

Vector and parallel coordinators call act() on every child before calling observe() on any child, so each returned observation follows the complete joint action batch.

Source code in src/mudgym/envs/env.py
def step(
    self,
    action: str,
) -> tuple[dict[str, Any], float, bool, bool, dict[str, Any]]:
    """Send one action, then receive its marker-framed observation.

    Vector and parallel coordinators call ``act()`` on every child before calling ``observe()``
    on any child, so each returned observation follows the complete joint action batch.
    """
    self.act(action)
    return self.observe()

act(action)

Send an action now, leaving its observation for a later observe call.

Source code in src/mudgym/envs/env.py
def act(self, action: str) -> None:
    """Send an action now, leaving its observation for a later ``observe`` call."""
    if not self.action_space.contains(action):
        raise ValueError(f"Invalid action {action!r}; expected {self.action_space}.")
    if self.points is None:
        raise RuntimeError("step called before reset established the persona score")
    self.session.send(action)
    self.step_count += 1

observe()

Receive everything up to this player's end-of-turn marker.

This includes the earlier action, the observation-command responses, and anything caused by other players since that action was sent.

Source code in src/mudgym/envs/env.py
def observe(self) -> tuple[dict[str, Any], float, bool, bool, dict[str, Any]]:
    """Receive everything up to this player's end-of-turn marker.

    This includes the earlier action, the observation-command responses, and anything caused by other players since that action was sent.
    """
    raw_bytes, terminated, truncated, debug_info = self.session.receive()

    # A truncated result means the read window ended without its marker: we left the game, the transport died, timed out, or otherwise stopped for a reason outside the MDP.
    obs, render_bytes, field_refusals = self.bytes_to_observation(
        raw_bytes,
        sent_lines=debug_info["sent_lines"],
        response_complete=bool(debug_info.get("marker_arrived", False)) and not truncated,
    )
    self.last_render_bytes = render_bytes
    info = self.make_info(
        raw_bytes=raw_bytes,
        render_bytes=render_bytes,
        rejected=bool(debug_info.get("rejected", False)),
        field_refusals=field_refusals,
    )

    # points events require colours a player cannot forge through the command echo
    points_before_step = self.points
    if points_before_step is None:
        raise RuntimeError("step called before reset established the persona score")

    points_changes = parse_points_changes(raw_bytes)
    event_points = points_changes["points"]
    # if we end up with points > WIZARD_POINTS, we terminate the episode.
    if event_points is not None and points_changes["delta"] and event_points >= WIZARD_POINTS:
        terminated = True

    # permadeath is a final points event at zero, after any numeric event
    if terminated and is_permadeath(raw_bytes):
        event_points = 0
    if event_points is not None:
        info["points"] = event_points
        self.points = event_points

    reward = float(self.points - points_before_step)

    # report tracked points even when the FES response is stale or missing
    if "points" in obs:
        obs["points"] = INT_DTYPE(self.points)

    if self.render_mode == "human":
        self.render()

    return obs, reward, terminated, truncated, info

mudgym.envs.vector.MudVectorEnv

Bases: VectorEnv

Source code in src/mudgym/envs/vector.py
class MudVectorEnv(VectorEnv):
    def __init__(
        self,
        envs: Sequence[MudEnv],
        provider: ConnectionProvider,
    ):
        if not envs:
            raise ValueError("MudVectorEnv requires at least one child MudEnv.")
        self.envs = list(envs)
        self._provider = provider
        self.metadata = dict(self.envs[0].metadata)
        self.metadata["autoreset_mode"] = AutoresetMode.DISABLED
        self.render_mode = self.envs[0].render_mode
        self.num_envs = len(self.envs)
        self.single_observation_space = self.envs[0].observation_space
        self.single_action_space = self.envs[0].action_space
        self.observation_space = batch_space(self.single_observation_space, self.num_envs)
        self.action_space = batch_space(self.single_action_space, self.num_envs)
        self._needs_reset = np.zeros(self.num_envs, dtype=np.bool_)

    def batch_observations(self, observations: Sequence[dict[str, Any]]):
        """Put child observations into the vector observation space."""
        output = create_empty_array(self.single_observation_space, n=self.num_envs, fn=np.empty)
        return concatenate(self.single_observation_space, observations, output)

    def batch_infos(self, infos: Sequence[dict[str, Any]]) -> dict[str, Any]:
        """Use Gymnasium's mask convention to combine child info dictionaries."""
        batched: dict[str, Any] = {}
        for index, info in enumerate(infos):
            batched = self._add_info(batched, info, index)
        return batched

    def child_seeds(self, seed: int | list[int | None] | None) -> list[int | None]:
        """Turn the vector seed into one Gym-side seed per child."""
        if seed is None:
            return [None] * self.num_envs
        if isinstance(seed, int):
            return [seed + index for index in range(self.num_envs)]
        if len(seed) != self.num_envs:
            raise ValueError(f"Seed list must contain {self.num_envs} entries, got {len(seed)}.")
        return list(seed)

    def reset(
        self,
        *,
        seed: int | list[int | None] | None = None,
        options: dict[str, Any] | None = None,
    ):
        if isinstance(seed, int):
            super().reset(seed=seed)
        self._provider.reset(seed=seed)
        seeds = self.child_seeds(seed)
        for child, child_seed in zip(self.envs, seeds, strict=True):
            child.reset(seed=child_seed, options=options)

        observations = []
        infos = []
        for index, child in enumerate(self.envs):
            observation, _, terminated, truncated, info = child.observe()
            if terminated or truncated:
                raise RuntimeError(
                    f"initial vector observation failed for child {index} "
                    f"(terminated={terminated}, truncated={truncated})"
                )
            observations.append(observation)
            infos.append(info)

        self._needs_reset[:] = False
        return self.batch_observations(observations), self.batch_infos(infos)

    def step(self, actions):
        """Send every child action before receiving any child observation; never autoreset."""
        if self._needs_reset.any():
            indices = np.flatnonzero(self._needs_reset).tolist()
            raise RuntimeError(f"Vector children {indices} are done; call reset() before stepping again.")

        child_actions = list(iterate(self.action_space, actions))
        # Don't fold these loops together. A player can affect another player's observation, so every action must reach the game before any observation commands are sent.
        for child, action in zip(self.envs, child_actions, strict=True):
            child.act(action)

        results = [child.observe() for child in self.envs]
        observations, rewards, terminations, truncations, infos = zip(*results, strict=True)
        terminations = np.asarray(terminations, dtype=np.bool_)
        truncations = np.asarray(truncations, dtype=np.bool_)
        self._needs_reset = np.logical_or(terminations, truncations)
        return (
            self.batch_observations(observations),
            np.asarray(rewards, dtype=np.float64),
            terminations,
            truncations,
            self.batch_infos(infos),
        )

    def render(self):
        return tuple(child.render() for child in self.envs)

    def close_extras(self, **kwargs):
        # Children own their connections and the provider owns whatever sits underneath them. Try every close even if one fails, then report the lot rather than leaking the rest.
        errors: list[Exception] = []
        for child in self.envs:
            try:
                child.close()
            except Exception as exc:
                errors.append(exc)
        try:
            self._provider.close()
        except Exception as exc:
            errors.append(exc)
        if errors:
            raise ExceptionGroup("MudVectorEnv close failed", errors)

batch_observations(observations)

Put child observations into the vector observation space.

Source code in src/mudgym/envs/vector.py
def batch_observations(self, observations: Sequence[dict[str, Any]]):
    """Put child observations into the vector observation space."""
    output = create_empty_array(self.single_observation_space, n=self.num_envs, fn=np.empty)
    return concatenate(self.single_observation_space, observations, output)

batch_infos(infos)

Use Gymnasium's mask convention to combine child info dictionaries.

Source code in src/mudgym/envs/vector.py
def batch_infos(self, infos: Sequence[dict[str, Any]]) -> dict[str, Any]:
    """Use Gymnasium's mask convention to combine child info dictionaries."""
    batched: dict[str, Any] = {}
    for index, info in enumerate(infos):
        batched = self._add_info(batched, info, index)
    return batched

child_seeds(seed)

Turn the vector seed into one Gym-side seed per child.

Source code in src/mudgym/envs/vector.py
def child_seeds(self, seed: int | list[int | None] | None) -> list[int | None]:
    """Turn the vector seed into one Gym-side seed per child."""
    if seed is None:
        return [None] * self.num_envs
    if isinstance(seed, int):
        return [seed + index for index in range(self.num_envs)]
    if len(seed) != self.num_envs:
        raise ValueError(f"Seed list must contain {self.num_envs} entries, got {len(seed)}.")
    return list(seed)

step(actions)

Send every child action before receiving any child observation; never autoreset.

Source code in src/mudgym/envs/vector.py
def step(self, actions):
    """Send every child action before receiving any child observation; never autoreset."""
    if self._needs_reset.any():
        indices = np.flatnonzero(self._needs_reset).tolist()
        raise RuntimeError(f"Vector children {indices} are done; call reset() before stepping again.")

    child_actions = list(iterate(self.action_space, actions))
    # Don't fold these loops together. A player can affect another player's observation, so every action must reach the game before any observation commands are sent.
    for child, action in zip(self.envs, child_actions, strict=True):
        child.act(action)

    results = [child.observe() for child in self.envs]
    observations, rewards, terminations, truncations, infos = zip(*results, strict=True)
    terminations = np.asarray(terminations, dtype=np.bool_)
    truncations = np.asarray(truncations, dtype=np.bool_)
    self._needs_reset = np.logical_or(terminations, truncations)
    return (
        self.batch_observations(observations),
        np.asarray(rewards, dtype=np.float64),
        terminations,
        truncations,
        self.batch_infos(infos),
    )

mudgym.envs.zoo.MudParallelEnv

Bases: ParallelEnv[str, dict[str, Any], str]

Coordinates several named players acting together in one shared MUD world.

Source code in src/mudgym/envs/zoo.py
class MudParallelEnv(ParallelEnv[str, dict[str, Any], str]):
    """Coordinates several named players acting together in one shared MUD world."""

    metadata = {
        "render_modes": ["ansi", "human"],
        "name": "mud2_v0",
    }

    def __init__(
        self,
        envs: dict[str, MudEnv],
        provider: ConnectionProvider,
        render_mode: str | None = None,
    ):
        if not envs:
            raise ValueError("MudParallelEnv requires at least one child MudEnv.")
        self.envs = dict(envs)
        self._provider = provider
        self.render_mode = render_mode

        self.possible_agents = list(self.envs)
        self.agents = list(self.possible_agents)

    def observation_space(self, agent: str):
        return self.envs[agent].observation_space

    def action_space(self, agent: str):
        return self.envs[agent].action_space

    def reset(
        self,
        seed: int | None = None,
        options: dict | None = None,
    ) -> tuple[dict[str, dict[str, Any]], dict[str, dict]]:
        self._provider.reset(seed=seed)
        self.agents = list(self.possible_agents)
        for index, agent in enumerate(self.agents):
            agent_seed = seed + index if seed is not None else None
            self.envs[agent].reset(seed=agent_seed, options=options)

        observations = {}
        infos = {}
        for agent in self.agents:
            observation, _, terminated, truncated, info = self.envs[agent].observe()
            if terminated or truncated:
                raise RuntimeError(
                    f"initial shared-world observation failed for {agent} "
                    f"(terminated={terminated}, truncated={truncated})"
                )
            observations[agent] = observation
            infos[agent] = info

        return observations, infos

    def step(
        self,
        actions: dict[str, str],
    ) -> tuple[
        dict[str, dict[str, Any]],
        dict[str, float],
        dict[str, bool],
        dict[str, bool],
        dict[str, dict],
    ]:
        observations = {}
        rewards = {}
        terminations = {}
        truncations = {}
        infos = {}

        agents = list(self.agents)
        # This ordering is the point of the coordinator: everybody acts before anybody runs their
        # observation commands. Folding the loops together would make later players invisible to
        # earlier observations from the same PettingZoo step.
        for agent in agents:
            self.envs[agent].act(actions[agent])

        for agent in agents:
            (
                observations[agent],
                rewards[agent],
                terminations[agent],
                truncations[agent],
                infos[agent],
            ) = self.envs[agent].observe()

        # An agent stays live until its own child says it is done. Keep the snapshot above for the result dictionaries, then update the public live-agent list for the next step.
        self.agents = [agent for agent in agents if not terminations[agent] and not truncations[agent]]

        return observations, rewards, terminations, truncations, infos

    def render_ansi(self) -> str:
        sections = []
        for agent in self.agents:
            child_frame = self.envs[agent].render()
            section = f"[{agent}]\n"
            if child_frame:
                section += child_frame
                if not section.endswith("\n"):
                    section += "\n"
            sections.append(section)
        return "".join(sections).rstrip("\n")

    def render(self) -> str | None:
        if self.render_mode is None:
            return None

        rendered = self.render_ansi()
        if self.render_mode == "ansi":
            return rendered

        if rendered:
            print(rendered, flush=True)
        return None

    def close(self) -> None:
        # Children own their connections; the provider owns the shared world beneath them. Attempt every close even if one fails so one awkward child does not leak everybody else's state.
        errors: list[Exception] = []
        for env in self.envs.values():
            try:
                env.close()
            except Exception as exc:
                errors.append(exc)
        try:
            self._provider.close()
        except Exception as exc:
            errors.append(exc)

        if errors:
            raise ExceptionGroup("MudParallelEnv close failed", errors)

Observation fields

mudgym.envs.fields.field.ObservationField

Bases: ABC

A self-contained, pure (no side effects) observation field.

A field declares: - the command that produces its bytes, if any; - the observation-space keys it owns; - empty defaults for those keys; - an extractor from response chunks to values.

A chunk is the game's output bytes for a single game event delimited by a game prompt marker.

The abstract methods (full_space(), full_empty(), full_extract()) declare the parser's full capability; space(), empty(), and extract() restrict that capability to include_keys and are what consumers read.

Source code in src/mudgym/envs/fields/field.py
class ObservationField(ABC):
    """
    A self-contained, pure (no side effects) observation field.

    A field declares:
    - the command that produces its bytes, if any;
    - the observation-space keys it owns;
    - empty defaults for those keys;
    - an extractor from response chunks to values.

    A `chunk` is the game's output bytes for a single game event delimited by a game prompt marker.

    The abstract methods (``full_space()``, ``full_empty()``, ``full_extract()``) declare the parser's
    full capability; ``space()``, ``empty()``, and ``extract()`` restrict that capability to
    ``include_keys`` and are what consumers read.
    """

    command: str | None = None

    # When this is the final observation field, this pattern identifies its response bytes as the step's
    # end of step marker.
    # None (the default) means the response is not distinctive enough to trust for end of step marking duty.
    end_of_turn_marker: re.Pattern[bytes] | None = None

    # When True (the default), the chunk this field claims is considered consumed and not included in the observation `text` key.
    remove_on_match: bool = True

    # Messages the game emits in place of a command's real output when the persona cannot act
    # (unconscious, asleep, ...). A refusal claims the field's slot but carries no data.
    # Unknown responses keep failing loudly in MudEnv's observation parser.
    PLAYER_STATE_REFUSALS: tuple[bytes, ...] = (
        b"You can't wake yourself up yet!",
        b"You can't see a thing, you're blind.",
    )

    def __init__(self, include_keys: Sequence[str] | None = None):
        """
        Args:
            include_keys: restrict this field's observation contribution to the given space() keys.
                None (default) keeps every key. An empty sequence keeps none, useful for using as an end of step marker.
        """
        if include_keys is None:
            self.include_keys: tuple[str, ...] | None = None
            return
        self.include_keys = tuple(include_keys)
        unknown_keys = set(self.include_keys) - set(self.full_space())
        if unknown_keys:
            raise ValueError(
                f"{self.__class__.__name__} include_keys {sorted(unknown_keys)} are "
                f"not in full_space() keys {sorted(self.full_space())}"
            )

    def filter_keys(self, values: Mapping[str, Any]) -> dict[str, Any]:
        """Restrict a full_space/full_empty/full_extract mapping to this field's include_keys."""
        if self.include_keys is None:
            return dict(values)
        return {key: value for key, value in values.items() if key in self.include_keys}

    def space(self) -> dict[str, spaces.Space]:
        """The observation-space slice this field contributes: ``full_space()`` restricted to include_keys."""
        return self.filter_keys(self.full_space())

    def empty(self) -> dict[str, Any]:
        """Default values for the contributed keys: ``full_empty()`` restricted to include_keys."""
        return self.filter_keys(self.full_empty())

    def extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
        """This field's observation contribution for a turn: ``full_extract()`` restricted to include_keys."""
        return self.filter_keys(self.full_extract(chunks, **context))

    @abstractmethod
    def full_space(self) -> dict[str, spaces.Space]:
        """Every observation-space key this field's parser can produce."""
        ...

    @abstractmethod
    def full_empty(self) -> dict[str, Any]:
        """Default values for every parser key when nothing matches (dtypes/shapes match ``full_space()``)."""
        ...

    @abstractmethod
    def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
        """Parse the turn's response ``chunks`` into every parser key. Pure: a function of its inputs alone.

        ``context`` carries observer facts the env supplies each call (currently ``persona``, the
        observing persona's bare name); a parser names what it consumes and ignores the rest.
        """
        ...

    def matches(self, chunk: bytes) -> bool:
        """Whether `chunk` is a valid output of this field's command.

        By default we just return True, but subclasses can override this to make matching more robust, in which case this
        method should return True for every output the command can really produce, including edge cases (eg, when dark, asleep,
        blind, etc)
        """
        return True

    def is_refusal(self, chunk: bytes) -> bool:
        """Whether ``chunk`` is a player-state refusal instead of this command's real output."""
        return strip_ansi(bytes(chunk)).strip() in self.PLAYER_STATE_REFUSALS

    def decode(self, raw_bytes: bytes) -> str:
        """Strip ANSI escape codes from the bytes and decode to text."""
        return decode_text_bytes(strip_ansi(bytes(raw_bytes)))

    def find_last_line(self, regex: re.Pattern[str], chunks: Sequence[bytes]) -> re.Match[str] | None:
        """Return the last line matching ``regex`` across the response chunks (or ``None``)."""
        match: re.Match[str] | None = None
        for chunk in chunks:
            for line in self.decode(chunk).splitlines():
                candidate = regex.match(line.strip())
                if candidate is not None:
                    match = candidate
        return match

__init__(include_keys=None)

Parameters:

Name Type Description Default
include_keys Sequence[str] | None

restrict this field's observation contribution to the given space() keys. None (default) keeps every key. An empty sequence keeps none, useful for using as an end of step marker.

None
Source code in src/mudgym/envs/fields/field.py
def __init__(self, include_keys: Sequence[str] | None = None):
    """
    Args:
        include_keys: restrict this field's observation contribution to the given space() keys.
            None (default) keeps every key. An empty sequence keeps none, useful for using as an end of step marker.
    """
    if include_keys is None:
        self.include_keys: tuple[str, ...] | None = None
        return
    self.include_keys = tuple(include_keys)
    unknown_keys = set(self.include_keys) - set(self.full_space())
    if unknown_keys:
        raise ValueError(
            f"{self.__class__.__name__} include_keys {sorted(unknown_keys)} are "
            f"not in full_space() keys {sorted(self.full_space())}"
        )

filter_keys(values)

Restrict a full_space/full_empty/full_extract mapping to this field's include_keys.

Source code in src/mudgym/envs/fields/field.py
def filter_keys(self, values: Mapping[str, Any]) -> dict[str, Any]:
    """Restrict a full_space/full_empty/full_extract mapping to this field's include_keys."""
    if self.include_keys is None:
        return dict(values)
    return {key: value for key, value in values.items() if key in self.include_keys}

space()

The observation-space slice this field contributes: full_space() restricted to include_keys.

Source code in src/mudgym/envs/fields/field.py
def space(self) -> dict[str, spaces.Space]:
    """The observation-space slice this field contributes: ``full_space()`` restricted to include_keys."""
    return self.filter_keys(self.full_space())

empty()

Default values for the contributed keys: full_empty() restricted to include_keys.

Source code in src/mudgym/envs/fields/field.py
def empty(self) -> dict[str, Any]:
    """Default values for the contributed keys: ``full_empty()`` restricted to include_keys."""
    return self.filter_keys(self.full_empty())

extract(chunks, **context)

This field's observation contribution for a turn: full_extract() restricted to include_keys.

Source code in src/mudgym/envs/fields/field.py
def extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
    """This field's observation contribution for a turn: ``full_extract()`` restricted to include_keys."""
    return self.filter_keys(self.full_extract(chunks, **context))

full_space() abstractmethod

Every observation-space key this field's parser can produce.

Source code in src/mudgym/envs/fields/field.py
@abstractmethod
def full_space(self) -> dict[str, spaces.Space]:
    """Every observation-space key this field's parser can produce."""
    ...

full_empty() abstractmethod

Default values for every parser key when nothing matches (dtypes/shapes match full_space()).

Source code in src/mudgym/envs/fields/field.py
@abstractmethod
def full_empty(self) -> dict[str, Any]:
    """Default values for every parser key when nothing matches (dtypes/shapes match ``full_space()``)."""
    ...

full_extract(chunks, **context) abstractmethod

Parse the turn's response chunks into every parser key. Pure: a function of its inputs alone.

context carries observer facts the env supplies each call (currently persona, the observing persona's bare name); a parser names what it consumes and ignores the rest.

Source code in src/mudgym/envs/fields/field.py
@abstractmethod
def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
    """Parse the turn's response ``chunks`` into every parser key. Pure: a function of its inputs alone.

    ``context`` carries observer facts the env supplies each call (currently ``persona``, the
    observing persona's bare name); a parser names what it consumes and ignores the rest.
    """
    ...

matches(chunk)

Whether chunk is a valid output of this field's command.

By default we just return True, but subclasses can override this to make matching more robust, in which case this method should return True for every output the command can really produce, including edge cases (eg, when dark, asleep, blind, etc)

Source code in src/mudgym/envs/fields/field.py
def matches(self, chunk: bytes) -> bool:
    """Whether `chunk` is a valid output of this field's command.

    By default we just return True, but subclasses can override this to make matching more robust, in which case this
    method should return True for every output the command can really produce, including edge cases (eg, when dark, asleep,
    blind, etc)
    """
    return True

is_refusal(chunk)

Whether chunk is a player-state refusal instead of this command's real output.

Source code in src/mudgym/envs/fields/field.py
def is_refusal(self, chunk: bytes) -> bool:
    """Whether ``chunk`` is a player-state refusal instead of this command's real output."""
    return strip_ansi(bytes(chunk)).strip() in self.PLAYER_STATE_REFUSALS

decode(raw_bytes)

Strip ANSI escape codes from the bytes and decode to text.

Source code in src/mudgym/envs/fields/field.py
def decode(self, raw_bytes: bytes) -> str:
    """Strip ANSI escape codes from the bytes and decode to text."""
    return decode_text_bytes(strip_ansi(bytes(raw_bytes)))

find_last_line(regex, chunks)

Return the last line matching regex across the response chunks (or None).

Source code in src/mudgym/envs/fields/field.py
def find_last_line(self, regex: re.Pattern[str], chunks: Sequence[bytes]) -> re.Match[str] | None:
    """Return the last line matching ``regex`` across the response chunks (or ``None``)."""
    match: re.Match[str] | None = None
    for chunk in chunks:
        for line in self.decode(chunk).splitlines():
            candidate = regex.match(line.strip())
            if candidate is not None:
                match = candidate
    return match

mudgym.envs.fields.rawbytes.RawBytesField

Bases: ObservationField

Returns the raw bytes from the game response, including ANSI escape codes, prompt markers and line breaks as a fixed-size uint8 numpy array.

Source code in src/mudgym/envs/fields/rawbytes.py
class RawBytesField(ObservationField):
    """
    Returns the raw bytes from the game response, including ANSI escape codes, prompt markers
    and line breaks as a fixed-size uint8 numpy array.
    """

    def __init__(self, max_bytes: int = DEFAULT_MAX_BYTES, include_keys: Sequence[str] | None = None):
        # max_bytes shapes space(), so it must be set before the base validates include_keys
        self.max_bytes = max_bytes
        super().__init__(include_keys=include_keys)

    def full_space(self) -> dict[str, spaces.Space]:
        return {
            "raw_bytes": spaces.Box(0, 255, shape=(self.max_bytes,), dtype=BYTE_DTYPE),
        }

    def full_empty(self) -> dict[str, Any]:
        return {
            "raw_bytes": np.zeros(self.max_bytes, dtype=BYTE_DTYPE),
        }

    def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
        # raw bytes wants the exact wire output, so re-join the per-command chunks
        payload = b"".join(chunks)

        if len(payload) > self.max_bytes:
            logger.warning(
                "field.raw_bytes.truncated",
                payload_bytes=len(payload),
                max_bytes=self.max_bytes,
            )
            payload = payload[: self.max_bytes]

        raw_array = np.zeros(self.max_bytes, dtype=BYTE_DTYPE)
        raw_array[: len(payload)] = np.frombuffer(payload, dtype=BYTE_DTYPE)
        return {"raw_bytes": raw_array}

mudgym.envs.fields.fescore.FEScoreField

Bases: ObservationField

Parsed FES line values
  • points (scalar)
  • vitals (8-dim) - stamina, max_stamina, effective_strength, strength, effective_dexterity, dexterity, magic, max_magic
  • flags (4-dim) - blind, deaf, crippled, dumb
  • reset_minutes (scalar)
  • weather (text) - fair, raining, snowing, etc.
  • weather_index (scalar) - index of the weather
Source code in src/mudgym/envs/fields/fescore.py
class FEScoreField(ObservationField):
    """
    Parsed FES line values:
      - points (scalar)
      - vitals (8-dim) - stamina, max_stamina, effective_strength, strength, effective_dexterity, dexterity, magic, max_magic
      - flags (4-dim) - blind, deaf, crippled, dumb
      - reset_minutes (scalar)
      - weather (text) - fair, raining, snowing, etc.
      - weather_index (scalar) - index of the weather
    """

    command = "fes"

    REGEX = re.compile(
        r"""^\s*
        (?P<stamina>\d+)\s+
        (?P<max_stamina>\d+)\s+
        (?P<effective_strength>\d+)\s+
        (?P<strength>\d+)\s+
        (?P<effective_dexterity>\d+)\s+
        (?P<dexterity>\d+)\s+
        (?P<magic>\d+)\s+
        (?P<max_magic>\d+)\s+
        (?P<points>\d{2,})\s+
        (?P<is_blind>[YN])\s+
        (?P<is_deaf>[YN])\s+
        (?P<is_crippled>[YN])\s+
        (?P<is_dumb>[YN])\s+
        (?P<reset_minutes>\d+)\s+
        (?P<weather>[SBRTCOF])\s*
        $""",
        re.VERBOSE | re.ASCII,
    )

    # The status line in wire form, mirroring REGEX above and tolerating the SGR codes the live
    # game interleaves around the coloured vitals. Runs of spaces separate tokens (like REGEX's
    # \s+, minus line breaks: a marker is one line); closes the read window when fes ends the batch.
    end_of_turn_marker = re.compile(
        rb"(?m)(?:^|\x1b\[[0-9;]*m)"
        + (rb"\d+" + SGR + rb" +" + SGR) * 8
        + rb"\d{2,}"
        + SGR
        + rb" +"
        + SGR
        + (rb"[YN]" + SGR + rb" +" + SGR) * 4
        + rb"\d+"
        + SGR
        + rb" +"
        + SGR
        + rb"[SBRTCOF]"
        + SGR
        + rb" ?\r?\n"
    )

    def full_space(self) -> dict[str, spaces.Space]:
        return {
            "points": spaces.Box(low=0, high=WIZARD_POINTS, shape=(), dtype=INT_DTYPE),
            "vitals": spaces.Box(low=0, high=200, shape=(8,), dtype=INT_DTYPE),
            "flags": spaces.MultiBinary(4),
            "reset_minutes": spaces.Box(low=0, high=MAX_RESET_MINUTES, shape=(), dtype=INT_DTYPE),
            "weather": spaces.Text(max_length=16, min_length=0, charset=SINGLE_LINE_CHARSET),
            "weather_index": spaces.Discrete(indexed_discrete_size(weather_count)),
        }

    def full_empty(self) -> dict[str, Any]:
        default_weather = "unknown"
        return {
            "points": INT_DTYPE(0),
            "vitals": np.zeros(8, dtype=INT_DTYPE),
            "flags": np.zeros(4, dtype=BIT_DTYPE),
            "reset_minutes": INT_DTYPE(0),
            "weather": default_weather,
            "weather_index": INDEX_DTYPE(weather_to_index(default_weather)),
        }

    def matches(self, chunk: bytes) -> bool:
        return any(self.REGEX.match(line.strip()) for line in self.decode(chunk).splitlines())

    def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
        """Parse the latest FES status line from the turn chunks, or the empty default if none is present."""
        match = self.find_last_line(self.REGEX, chunks)
        if match is None:
            return self.full_empty()

        weather_name = WEATHER_CODE_TO_NAME.get(match.group("weather"), "unknown")

        vitals = np.array(
            [
                int(match.group("stamina")),
                int(match.group("max_stamina")),
                int(match.group("effective_strength")),
                int(match.group("strength")),
                int(match.group("effective_dexterity")),
                int(match.group("dexterity")),
                int(match.group("magic")),
                int(match.group("max_magic")),
            ],
            dtype=INT_DTYPE,
        )

        flags = np.array(
            [
                int(match.group("is_blind") == "Y"),
                int(match.group("is_deaf") == "Y"),
                int(match.group("is_crippled") == "Y"),
                int(match.group("is_dumb") == "Y"),
            ],
            dtype=BIT_DTYPE,
        )

        return {
            "points": INT_DTYPE(int(match.group("points"))),
            "vitals": vitals,
            "flags": flags,
            "reset_minutes": INT_DTYPE(int(match.group("reset_minutes"))),
            "weather": weather_name,
            "weather_index": INDEX_DTYPE(weather_to_index(weather_name)),
        }

full_extract(chunks, **context)

Parse the latest FES status line from the turn chunks, or the empty default if none is present.

Source code in src/mudgym/envs/fields/fescore.py
def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
    """Parse the latest FES status line from the turn chunks, or the empty default if none is present."""
    match = self.find_last_line(self.REGEX, chunks)
    if match is None:
        return self.full_empty()

    weather_name = WEATHER_CODE_TO_NAME.get(match.group("weather"), "unknown")

    vitals = np.array(
        [
            int(match.group("stamina")),
            int(match.group("max_stamina")),
            int(match.group("effective_strength")),
            int(match.group("strength")),
            int(match.group("effective_dexterity")),
            int(match.group("dexterity")),
            int(match.group("magic")),
            int(match.group("max_magic")),
        ],
        dtype=INT_DTYPE,
    )

    flags = np.array(
        [
            int(match.group("is_blind") == "Y"),
            int(match.group("is_deaf") == "Y"),
            int(match.group("is_crippled") == "Y"),
            int(match.group("is_dumb") == "Y"),
        ],
        dtype=BIT_DTYPE,
    )

    return {
        "points": INT_DTYPE(int(match.group("points"))),
        "vitals": vitals,
        "flags": flags,
        "reset_minutes": INT_DTYPE(int(match.group("reset_minutes"))),
        "weather": weather_name,
        "weather_index": INDEX_DTYPE(weather_to_index(weather_name)),
    }

mudgym.envs.fields.fexits.FEXitsField

Bases: ObservationField

FEX exit data field.

Provides known available exits as
  • available_exits: MultiBinary vector over all directions.
  • available_exit_names: Tuple of direction names.
Source code in src/mudgym/envs/fields/fexits.py
class FEXitsField(ObservationField):
    """
    FEX exit data field.

    Provides known available exits as:
      - available_exits: MultiBinary vector over all directions.
      - available_exit_names: Tuple of direction names.
    """

    command = "fex"

    # Each direction must be followed by whitespace or end-of-line.
    DIRECTION_GROUP = r"(?:" + "|".join(re.escape(direction) for direction in DIRECTIONS) + r")(?=\s|$)"
    REGEX = re.compile(
        rf"^\s*(?P<exits>{DIRECTION_GROUP}(?:\s+{DIRECTION_GROUP})*)\s*$",
        flags=re.ASCII,
    )

    def full_space(self) -> dict[str, spaces.Space]:
        return {
            "available_exits": spaces.MultiBinary(direction_count),
            "available_exit_names": spaces.Sequence(
                spaces.Text(
                    max_length=MAX_DIRECTION_LENGTH,
                    min_length=0,
                    charset=IDENTIFIER_CHARSET,
                ),
                stack=False,
            ),
        }

    def full_empty(self) -> dict[str, Any]:
        # we return all exits available for the empty/unknown case so we don't get stuck, this is practical, even if not pure
        return all_exits()

    def matches(self, chunk: bytes) -> bool:
        lines = [line.strip() for line in self.decode(chunk).splitlines()]
        # a dark room returns a blank exits response, which is a valid, if uninformative
        if not any(lines):
            return True
        return any(self.REGEX.match(line) for line in lines)

    def exits_to_vector(self, exit_names: Sequence[str]) -> np.ndarray:
        vector = np.zeros(direction_count, dtype=BIT_DTYPE)
        for d in exit_names:
            try:
                vector[DIRECTION_INDEX_BY_NAME[d]] = 1
            except KeyError:
                raise ValueError(f"Unknown direction in fex output: {d!r}") from None
        return vector

    def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
        """Parse the latest FEX exits line.

        When no exits line is recognised (e.g. a dark room returns a blank exits response), default to all
        exits available so the agent can still attempt any direction.
        """
        match = self.find_last_line(self.REGEX, chunks)
        if match is None:
            return all_exits()

        available_exits = self.exits_to_vector(match.group("exits").split())
        return {
            "available_exits": available_exits,
            "available_exit_names": tuple(
                direction for index, direction in enumerate(DIRECTIONS) if available_exits[index]
            ),
        }

full_extract(chunks, **context)

Parse the latest FEX exits line.

When no exits line is recognised (e.g. a dark room returns a blank exits response), default to all exits available so the agent can still attempt any direction.

Source code in src/mudgym/envs/fields/fexits.py
def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
    """Parse the latest FEX exits line.

    When no exits line is recognised (e.g. a dark room returns a blank exits response), default to all
    exits available so the agent can still attempt any direction.
    """
    match = self.find_last_line(self.REGEX, chunks)
    if match is None:
        return all_exits()

    available_exits = self.exits_to_vector(match.group("exits").split())
    return {
        "available_exits": available_exits,
        "available_exit_names": tuple(
            direction for index, direction in enumerate(DIRECTIONS) if available_exits[index]
        ),
    }

mudgym.envs.fields.feinventory.FEInventoryField

Bases: ObservationField

Parses the fei command output, split by the inventory divider into portables (lying around) and the player's own inventory.

Source code in src/mudgym/envs/fields/feinventory.py
class FEInventoryField(ObservationField):
    """
    Parses the ``fei`` command output, split by the inventory divider into portables (lying around) and
    the player's own inventory.
    """

    command = "fei"

    # the ======== divider
    end_of_turn_marker = re.compile(rb"(?m)(?:^|\x1b\[[0-9;]*m)========\r?\n")

    def full_space(self) -> dict[str, spaces.Space]:
        return {
            # the real fei grammar emits identifiers only ("brand39", "cloth-of-gold", "key50" in
            # the live captures), never descriptive phrases
            "portables": IDENTIFIER_SPACE,
            "inventory": IDENTIFIER_SPACE,
        }

    def full_empty(self) -> dict[str, Any]:
        return {
            "portables": (),
            "inventory": (),
        }

    def matches(self, chunk: bytes) -> bool:
        return INVENTORY_DIVIDER in chunk

    def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
        """Find the fei response chunk and split it on the divider into portables / inventory."""
        chunk = next(
            (c for c in reversed(chunks) if INVENTORY_DIVIDER in c),
            None,
        )
        if chunk is None:
            return self.full_empty()

        before, _, after = chunk.partition(INVENTORY_DIVIDER)
        portables = tuple(
            item for item in parse_inventory_lines(before) if item not in (DARK_PORTABLES, BLIND_PORTABLES)
        )
        return {
            "portables": portables,
            "inventory": parse_inventory_lines(after),
        }

full_extract(chunks, **context)

Find the fei response chunk and split it on the divider into portables / inventory.

Source code in src/mudgym/envs/fields/feinventory.py
def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
    """Find the fei response chunk and split it on the divider into portables / inventory."""
    chunk = next(
        (c for c in reversed(chunks) if INVENTORY_DIVIDER in c),
        None,
    )
    if chunk is None:
        return self.full_empty()

    before, _, after = chunk.partition(INVENTORY_DIVIDER)
    portables = tuple(
        item for item in parse_inventory_lines(before) if item not in (DARK_PORTABLES, BLIND_PORTABLES)
    )
    return {
        "portables": portables,
        "inventory": parse_inventory_lines(after),
    }

mudgym.envs.fields.superquicklook.SuperQuickLookField

Bases: ObservationField

Parses room contents and inventory from the superquicklook command. Pure: reads the step bytes and returns its own keys only.

Source code in src/mudgym/envs/fields/superquicklook.py
class SuperQuickLookField(ObservationField):
    """
    Parses room contents and inventory from the superquicklook command. Pure: reads the step bytes and
    returns its own keys only.
    """

    command = "sql"

    def full_space(self) -> dict[str, spaces.Space]:
        return {
            "room_name": spaces.Text(max_length=ROOM_NAME_MAX_LENGTH, min_length=0, charset=SINGLE_LINE_CHARSET),
            "room_name_index": spaces.Discrete(indexed_discrete_size(room_name_count)),
            "here": ITEM_SPACE,
            "inventory": ITEM_SPACE,
            "features": ITEM_SPACE,
            "portables": ITEM_SPACE,
            "mobiles": ITEM_SPACE,
            "players": ITEM_SPACE,
        }

    def full_empty(self) -> dict[str, Any]:
        return {
            "room_name": "",
            "room_name_index": INDEX_DTYPE(0),
            "here": (),
            "inventory": (),
            "features": (),
            "portables": (),
            "mobiles": (),
            "players": (),
        }

    def matches(self, chunk: bytes) -> bool:
        return ROOM_MARKER_BYTES in chunk or b"It's too dark for you to see anything." in chunk

    def full_extract(self, chunks: Sequence[bytes], *, persona: str | None = None, **context: Any) -> dict[str, Any]:
        """Parse the latest superquicklook room view, or the empty default if none is present."""
        raw_bytes = b"".join(chunks)
        if ROOM_MARKER_BYTES not in raw_bytes:
            return self.full_empty()

        text = decode_text_bytes(raw_bytes)
        room_match = find_last_room_line(text)
        if room_match is None:
            return self.full_empty()

        _, room_name = parse_token(room_match.group("place"))
        room_name = room_name.lower()

        here, classified = parse_room_contents(room_match.group("contents"))

        clean_text = decode_text_bytes(strip_ansi(raw_bytes))
        block_start = clean_text.rfind(ROOM_MARKER)
        if block_start == -1:
            block_start = 0

        _, inventory = parse_carrying_and_inventory(clean_text, block_start)

        # exclude the current persona from the players list, since we don't want to include ourselves in the
        # observation. the listing gives name with level (which isn't always first, eg, Sir Dave)
        players = tuple(name for name in classified["players"] if persona is None or bare_persona_name(name) != persona)

        return {
            "room_name": room_name,
            "room_name_index": INDEX_DTYPE(room_name_to_index(room_name) if room_name else 0),
            "here": here,
            "inventory": inventory,
            "features": tuple(classified["features"]),
            "portables": tuple(classified["portables"]),
            "mobiles": tuple(classified["mobiles"]),
            "players": players,
        }

full_extract(chunks, *, persona=None, **context)

Parse the latest superquicklook room view, or the empty default if none is present.

Source code in src/mudgym/envs/fields/superquicklook.py
def full_extract(self, chunks: Sequence[bytes], *, persona: str | None = None, **context: Any) -> dict[str, Any]:
    """Parse the latest superquicklook room view, or the empty default if none is present."""
    raw_bytes = b"".join(chunks)
    if ROOM_MARKER_BYTES not in raw_bytes:
        return self.full_empty()

    text = decode_text_bytes(raw_bytes)
    room_match = find_last_room_line(text)
    if room_match is None:
        return self.full_empty()

    _, room_name = parse_token(room_match.group("place"))
    room_name = room_name.lower()

    here, classified = parse_room_contents(room_match.group("contents"))

    clean_text = decode_text_bytes(strip_ansi(raw_bytes))
    block_start = clean_text.rfind(ROOM_MARKER)
    if block_start == -1:
        block_start = 0

    _, inventory = parse_carrying_and_inventory(clean_text, block_start)

    # exclude the current persona from the players list, since we don't want to include ourselves in the
    # observation. the listing gives name with level (which isn't always first, eg, Sir Dave)
    players = tuple(name for name in classified["players"] if persona is None or bare_persona_name(name) != persona)

    return {
        "room_name": room_name,
        "room_name_index": INDEX_DTYPE(room_name_to_index(room_name) if room_name else 0),
        "here": here,
        "inventory": inventory,
        "features": tuple(classified["features"]),
        "portables": tuple(classified["portables"]),
        "mobiles": tuple(classified["mobiles"]),
        "players": players,
    }

mudgym.envs.fields.mgcheats.MGCheatsField

Bases: ObservationField

Reads the mgcheats block.

Sample game response: [mgcheats]room_id=mtrack1; room_name=beaten track near cliff; fighting=0; dark=0; glowing=0; asleep=0; gifted=0; here=[rain, cliff, road]; inventory=[][/mgcheats]

Source code in src/mudgym/envs/fields/mgcheats.py
class MGCheatsField(ObservationField):
    """
    Reads the mgcheats block.

    Sample game response:
    `[mgcheats]room_id=mtrack1; room_name=beaten track near cliff; fighting=0; dark=0; glowing=0; asleep=0; gifted=0; here=[rain, cliff, road]; inventory=[][/mgcheats]`
    """

    command = "mgcheats"

    # the closing tag closes the read window when mgcheats is the final observation command
    end_of_turn_marker = re.compile(rb"\[/mgcheats\]\r?\n")

    BIT_KEYS = ("fighting", "dark", "glowing", "asleep", "gifted")

    def full_space(self) -> dict[str, spaces.Space]:
        return {
            "room_id": spaces.Text(max_length=ROOM_ID_MAX_LENGTH, min_length=0, charset=IDENTIFIER_CHARSET),
            "room_id_index": spaces.Discrete(indexed_discrete_size(room_id_count)),
            "room_name": spaces.Text(max_length=ROOM_NAME_MAX_LENGTH, min_length=0, charset=SINGLE_LINE_CHARSET),
            "room_name_index": spaces.Discrete(indexed_discrete_size(room_name_count)),
            "fighting": spaces.Discrete(2, dtype=BIT_DTYPE),
            "dark": spaces.Discrete(2, dtype=BIT_DTYPE),
            "glowing": spaces.Discrete(2, dtype=BIT_DTYPE),
            "asleep": spaces.Discrete(2, dtype=BIT_DTYPE),
            "gifted": spaces.Discrete(2, dtype=BIT_DTYPE),
            "here": IDENTIFIER_SPACE,
        }

    def full_empty(self) -> dict[str, Any]:
        return {
            "room_id": "",
            "room_id_index": INDEX_DTYPE(0),
            "room_name": "",
            "room_name_index": INDEX_DTYPE(0),
            **{k: BIT_DTYPE(0) for k in self.BIT_KEYS},
            "here": (),
        }

    def matches(self, chunk: bytes) -> bool:
        return MGCHEATS_BLOCK.search(chunk) is not None

    def parse(self, payload_bytes: bytes) -> dict[str, Any]:
        """Parse a single mgcheats payload (semicolon-separated ``key=value`` pairs) into a dict of values."""
        parsed_values: dict[str, Any] = {}
        text = decode_text_bytes(payload_bytes).strip()
        for part in (segment.strip() for segment in text.split(";")):
            if not part or "=" not in part:
                continue
            key, raw_value = part.split("=", 1)
            key = key.strip()
            raw_value = raw_value.strip()
            lowered = raw_value.lower()

            if raw_value.startswith("[") and raw_value.endswith("]"):
                inner = raw_value[1:-1].strip()
                if inner:
                    entries = [item.strip() for item in inner.split(",") if item.strip()]
                    parsed_values[key] = [entry.lower() for entry in entries]
                else:
                    parsed_values[key] = []
            else:
                parsed_values[key] = int(lowered) if lowered in ("0", "1") else lowered
        return parsed_values

    def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
        """Parse the latest ``[mgcheats]`` block, or the empty default if none is present."""
        payloads = MGCHEATS_BLOCK.findall(b"".join(chunks))
        if not payloads:
            return self.full_empty()

        parsed = self.parse(payloads[-1])

        room_id = str(parsed.get("room_id", ""))
        room_name = str(parsed.get("room_name", ""))

        return {
            "room_id": room_id,
            "room_id_index": INDEX_DTYPE(room_id_to_index(room_id) if room_id else 0),
            "room_name": room_name,
            "room_name_index": INDEX_DTYPE(room_name_to_index(room_name) if room_name else 0),
            **{key: BIT_DTYPE(int(parsed.get(key, 0))) for key in self.BIT_KEYS},
            "here": tuple(str(item) for item in (parsed.get("here") or [])),
        }

parse(payload_bytes)

Parse a single mgcheats payload (semicolon-separated key=value pairs) into a dict of values.

Source code in src/mudgym/envs/fields/mgcheats.py
def parse(self, payload_bytes: bytes) -> dict[str, Any]:
    """Parse a single mgcheats payload (semicolon-separated ``key=value`` pairs) into a dict of values."""
    parsed_values: dict[str, Any] = {}
    text = decode_text_bytes(payload_bytes).strip()
    for part in (segment.strip() for segment in text.split(";")):
        if not part or "=" not in part:
            continue
        key, raw_value = part.split("=", 1)
        key = key.strip()
        raw_value = raw_value.strip()
        lowered = raw_value.lower()

        if raw_value.startswith("[") and raw_value.endswith("]"):
            inner = raw_value[1:-1].strip()
            if inner:
                entries = [item.strip() for item in inner.split(",") if item.strip()]
                parsed_values[key] = [entry.lower() for entry in entries]
            else:
                parsed_values[key] = []
        else:
            parsed_values[key] = int(lowered) if lowered in ("0", "1") else lowered
    return parsed_values

full_extract(chunks, **context)

Parse the latest [mgcheats] block, or the empty default if none is present.

Source code in src/mudgym/envs/fields/mgcheats.py
def full_extract(self, chunks: Sequence[bytes], **context: Any) -> dict[str, Any]:
    """Parse the latest ``[mgcheats]`` block, or the empty default if none is present."""
    payloads = MGCHEATS_BLOCK.findall(b"".join(chunks))
    if not payloads:
        return self.full_empty()

    parsed = self.parse(payloads[-1])

    room_id = str(parsed.get("room_id", ""))
    room_name = str(parsed.get("room_name", ""))

    return {
        "room_id": room_id,
        "room_id_index": INDEX_DTYPE(room_id_to_index(room_id) if room_id else 0),
        "room_name": room_name,
        "room_name_index": INDEX_DTYPE(room_name_to_index(room_name) if room_name else 0),
        **{key: BIT_DTYPE(int(parsed.get(key, 0))) for key in self.BIT_KEYS},
        "here": tuple(str(item) for item in (parsed.get("here") or [])),
    }

Action wrappers

mudgym.envs.actions.discrete.DiscreteActionSpaceWrapper

Bases: ActionWrapper

Sets the action space to a discrete categorical multiple choice space. Maps discrete actions (ints) to string commands for the underlying env.

We use command to refer to the text sent to the game and action as the RL/gymnasium side concept.

Source code in src/mudgym/envs/actions/discrete.py
class DiscreteActionSpaceWrapper(ActionWrapper):
    """
    Sets the action space to a discrete categorical multiple choice space.
    Maps discrete actions (ints) to string commands for the underlying env.

    We use `command` to refer to the text sent to the game and `action` as the
    RL/gymnasium side concept.
    """

    def __init__(self, env, commands):
        super().__init__(env)
        self.discrete_actions = DiscreteActions(commands)
        self.commands = self.discrete_actions.commands
        self.action_count = len(self.commands)
        self.action_space = self.discrete_actions.space

    def action(self, index):
        """
        Map discrete index to command string.
        """
        return self.discrete_actions.command(index)

action(index)

Map discrete index to command string.

Source code in src/mudgym/envs/actions/discrete.py
def action(self, index):
    """
    Map discrete index to command string.
    """
    return self.discrete_actions.command(index)

mudgym.envs.actions.discrete.DiscreteDirectionsWrapper

Bases: DiscreteActionSpaceWrapper

Set the action space to include the movement directions.

Source code in src/mudgym/envs/actions/discrete.py
class DiscreteDirectionsWrapper(DiscreteActionSpaceWrapper):
    """
    Set the action space to include the movement directions.
    """

    def __init__(self, env):
        super().__init__(env, commands=DIRECTION_COMMANDS)

mudgym.envs.actions.discrete.ParallelDiscreteActionSpaceWrapper

Bases: BaseParallelWrapper

Map every agent's discrete action before forwarding one parallel step.

Source code in src/mudgym/envs/actions/discrete.py
class ParallelDiscreteActionSpaceWrapper(BaseParallelWrapper):
    """Map every agent's discrete action before forwarding one parallel step."""

    def __init__(self, env: ParallelEnv, commands):
        super().__init__(env)
        self.discrete_actions = DiscreteActions(commands)
        self.commands = self.discrete_actions.commands
        self.action_count = len(self.commands)
        # Each agent owns a separate space, and therefore a separate sampling RNG stream.
        self.action_spaces = {agent: self.discrete_actions.make_space() for agent in env.possible_agents}

    def action_space(self, agent):
        return self.action_spaces[agent]

    def step(self, actions):
        commands = {agent: self.discrete_actions.command(action) for agent, action in actions.items()}
        return self.env.step(commands)

mudgym.envs.actions.discrete.ParallelDiscreteDirectionsWrapper

Bases: ParallelDiscreteActionSpaceWrapper

Set every agent's action space to the movement directions.

Source code in src/mudgym/envs/actions/discrete.py
class ParallelDiscreteDirectionsWrapper(ParallelDiscreteActionSpaceWrapper):
    """Set every agent's action space to the movement directions."""

    def __init__(self, env):
        super().__init__(env, commands=DIRECTION_COMMANDS)

Connections

mudgym.connections.connection.MudConnection

Base class for managing connections to MUD2 game instances.

The state machine handles the fiddly lifecycle. This class wraps it so we can swap in different transports.

Lifecycle: - reset() gets us to TEA_SIPPED, ready for an episode to begin. The env's reset then exits the tearoom to start that episode. - send_line() and read_response() split sending from receiving so several players can see each other's actions. - close() terminates the child process. Use reset() instead when the connection will be reused for another episode.

Source code in src/mudgym/connections/connection.py
class MudConnection:
    """
    Base class for managing connections to MUD2 game instances.

    The state machine handles the fiddly lifecycle. This class wraps it so we can swap in different
    transports.

    Lifecycle:
    - ``reset()`` gets us to ``TEA_SIPPED``, ready for an episode to begin. The env's reset then
      exits the tearoom to start that episode.
    - ``send_line()`` and ``read_response()`` split sending from receiving so several players can
      see each other's actions.
    - ``close()`` terminates the child process. Use ``reset()`` instead when the connection will be
      reused for another episode.
    """

    # initial prompt we expect to see - subclasses can override
    initial_prompt: PromptSpec | None = None

    def __init__(
        self,
        *,
        account_id: str = "",
        password: str = "",
        persona_slot: int | None = None,
        db_slot: int | None = None,
        name_generator: Callable[[], str] | None = None,
    ):
        # I'm not convinced we actually need many of these anymore, but they're here for now.
        self.account_id = account_id
        self.password = password
        self.persona_slot = persona_slot
        self.db_slot = db_slot
        self.name_generator = name_generator

        # our state machine instance, that does most of the heavy lifting
        self.sm: ConnectionState | None = None
        self._pending_lines: list[str] = []

    def spawn(self) -> pexpect.spawn:
        """
        The method that does the actual connecting by spawning and returning our child process.
        """
        child = pexpect.spawn(
            self.command[0],
            self.command[1:] if len(self.command) > 1 else [],
            encoding=None,
            use_poll=True,  # poll() instead of select() to avoid FD_SETSIZE limit
        )
        # reduce pexpect's pause before each send - defaults to 0.05 (in seconds)
        child.delaybeforesend = 0.005
        return child

    def reset(self) -> None:
        """
        Resets the connection to be ready to start a new episode (TEA_SIPPED state).

        This tells us the `MudConnection` is ready but the `MudEnv` has its own `reset()` steps afterwards that does
        episode related things that don't make sense here, like issuing commands to set up the initial environment state
        (eg, score), running observation commands, and taking the northwards step outside of the tearoom.

        I can imagine a situation with multiple `MudConnection`s waiting on each other after `reset()` to be ready so it
        seemed negligent to leave agents hanging around outside of the sanctity of the Tearoom where they might get
        attacked by mobiles or something.
        """

        self._pending_lines.clear()

        # do we need to respawn the process or can we reuse via some menu choices?
        needs_respawn = self.sm is None or not self.sm.isalive()
        logger.debug(
            "connection.reset.start",
            sm_state=self.sm.state.name if self.sm is not None else None,
            needs_respawn=needs_respawn,
        )

        if self.sm is not None and self.sm.isalive():
            # reset-quit: leave The Land but stay in the mudlogin menu if our connection type
            # supports that (ie, not a quicklogin, which exits to DEAD)
            self.sm.quit()

            if self.sm.state == State.DEAD:
                needs_respawn = True

        if needs_respawn:
            logger.debug("connection.reset.spawn")
            child = self.spawn()
            self.sm = ConnectionState(
                child=child,
                account_id=self.account_id,
                password=self.password,
                persona_slot=self.persona_slot,
                db_slot=self.db_slot,
                name_generator=self.name_generator,
                initial_prompt=self.initial_prompt,
            )

        # OPTION -> persona selection/creation -> TEAROOM -> sip tea -> TEA_SIPPED
        logger.debug("connection.reset.continue_until_tea", sm_state=self.sm.state.name)
        self.sm.continue_until(State.TEA_SIPPED)
        logger.debug(
            "connection.reset.complete",
            sm_state=self.sm.state.name,
            last_prompt=self.sm.last_prompt.name if self.sm.last_prompt else None,
        )

    def send_line(self, line: str) -> None:
        """Send a line without waiting for its response."""
        if self.sm is None:
            raise RuntimeError("Connection has not been reset, call reset() first.")
        self.sm.send(line)
        # only lines that made it onto the wire belong to the response we drain later
        self._pending_lines.append(line)

    def read_response(
        self,
        end_of_turn_marker: re.Pattern,
    ) -> tuple[bytes, bool, bool, dict[str, Any]]:
        """Read the response up to the marker for lines already sent through ``send_line``.

        The connection remembers which lines were actually sent, which lets the state machine find
        their echoes without asking the caller to reconstruct the wire history afterwards.
        """
        if self.sm is None:
            raise RuntimeError("Connection has not been reset, call reset() first.")
        if not self._pending_lines:
            raise RuntimeError("No command lines are awaiting a response.")

        lines = self._pending_lines
        self._pending_lines = []
        raw_bytes, terminated, incomplete, debug_info = self.sm.read(lines, end_of_turn_marker)
        debug_info["sent_lines"] = list(lines)
        return raw_bytes, terminated, incomplete, debug_info

    def invalidate(self) -> None:
        """Throw away a desynchronised transport so the next reset has to spawn a fresh one."""
        self._pending_lines.clear()
        if self.sm is None:
            return
        state_machine = self.sm
        try:
            if state_machine.isalive():
                state_machine.quit()
        except RuntimeError as exc:
            logger.debug("connection.invalidate.quit_failed", exc_info=exc)
        finally:
            state_machine.close(force=True)
            self.sm = None

    def close(self):
        self._pending_lines.clear()
        if self.sm is None:
            return
        try:
            if self.sm.isalive():
                self.sm.quit()
        finally:
            self.sm.close()
            self.sm = None

spawn()

The method that does the actual connecting by spawning and returning our child process.

Source code in src/mudgym/connections/connection.py
def spawn(self) -> pexpect.spawn:
    """
    The method that does the actual connecting by spawning and returning our child process.
    """
    child = pexpect.spawn(
        self.command[0],
        self.command[1:] if len(self.command) > 1 else [],
        encoding=None,
        use_poll=True,  # poll() instead of select() to avoid FD_SETSIZE limit
    )
    # reduce pexpect's pause before each send - defaults to 0.05 (in seconds)
    child.delaybeforesend = 0.005
    return child

reset()

Resets the connection to be ready to start a new episode (TEA_SIPPED state).

This tells us the MudConnection is ready but the MudEnv has its own reset() steps afterwards that does episode related things that don't make sense here, like issuing commands to set up the initial environment state (eg, score), running observation commands, and taking the northwards step outside of the tearoom.

I can imagine a situation with multiple MudConnections waiting on each other after reset() to be ready so it seemed negligent to leave agents hanging around outside of the sanctity of the Tearoom where they might get attacked by mobiles or something.

Source code in src/mudgym/connections/connection.py
def reset(self) -> None:
    """
    Resets the connection to be ready to start a new episode (TEA_SIPPED state).

    This tells us the `MudConnection` is ready but the `MudEnv` has its own `reset()` steps afterwards that does
    episode related things that don't make sense here, like issuing commands to set up the initial environment state
    (eg, score), running observation commands, and taking the northwards step outside of the tearoom.

    I can imagine a situation with multiple `MudConnection`s waiting on each other after `reset()` to be ready so it
    seemed negligent to leave agents hanging around outside of the sanctity of the Tearoom where they might get
    attacked by mobiles or something.
    """

    self._pending_lines.clear()

    # do we need to respawn the process or can we reuse via some menu choices?
    needs_respawn = self.sm is None or not self.sm.isalive()
    logger.debug(
        "connection.reset.start",
        sm_state=self.sm.state.name if self.sm is not None else None,
        needs_respawn=needs_respawn,
    )

    if self.sm is not None and self.sm.isalive():
        # reset-quit: leave The Land but stay in the mudlogin menu if our connection type
        # supports that (ie, not a quicklogin, which exits to DEAD)
        self.sm.quit()

        if self.sm.state == State.DEAD:
            needs_respawn = True

    if needs_respawn:
        logger.debug("connection.reset.spawn")
        child = self.spawn()
        self.sm = ConnectionState(
            child=child,
            account_id=self.account_id,
            password=self.password,
            persona_slot=self.persona_slot,
            db_slot=self.db_slot,
            name_generator=self.name_generator,
            initial_prompt=self.initial_prompt,
        )

    # OPTION -> persona selection/creation -> TEAROOM -> sip tea -> TEA_SIPPED
    logger.debug("connection.reset.continue_until_tea", sm_state=self.sm.state.name)
    self.sm.continue_until(State.TEA_SIPPED)
    logger.debug(
        "connection.reset.complete",
        sm_state=self.sm.state.name,
        last_prompt=self.sm.last_prompt.name if self.sm.last_prompt else None,
    )

send_line(line)

Send a line without waiting for its response.

Source code in src/mudgym/connections/connection.py
def send_line(self, line: str) -> None:
    """Send a line without waiting for its response."""
    if self.sm is None:
        raise RuntimeError("Connection has not been reset, call reset() first.")
    self.sm.send(line)
    # only lines that made it onto the wire belong to the response we drain later
    self._pending_lines.append(line)

read_response(end_of_turn_marker)

Read the response up to the marker for lines already sent through send_line.

The connection remembers which lines were actually sent, which lets the state machine find their echoes without asking the caller to reconstruct the wire history afterwards.

Source code in src/mudgym/connections/connection.py
def read_response(
    self,
    end_of_turn_marker: re.Pattern,
) -> tuple[bytes, bool, bool, dict[str, Any]]:
    """Read the response up to the marker for lines already sent through ``send_line``.

    The connection remembers which lines were actually sent, which lets the state machine find
    their echoes without asking the caller to reconstruct the wire history afterwards.
    """
    if self.sm is None:
        raise RuntimeError("Connection has not been reset, call reset() first.")
    if not self._pending_lines:
        raise RuntimeError("No command lines are awaiting a response.")

    lines = self._pending_lines
    self._pending_lines = []
    raw_bytes, terminated, incomplete, debug_info = self.sm.read(lines, end_of_turn_marker)
    debug_info["sent_lines"] = list(lines)
    return raw_bytes, terminated, incomplete, debug_info

invalidate()

Throw away a desynchronised transport so the next reset has to spawn a fresh one.

Source code in src/mudgym/connections/connection.py
def invalidate(self) -> None:
    """Throw away a desynchronised transport so the next reset has to spawn a fresh one."""
    self._pending_lines.clear()
    if self.sm is None:
        return
    state_machine = self.sm
    try:
        if state_machine.isalive():
            state_machine.quit()
    except RuntimeError as exc:
        logger.debug("connection.invalidate.quit_failed", exc_info=exc)
    finally:
        state_machine.close(force=True)
        self.sm = None

mudgym.connections.docker_run.DockerRunConnection

Bases: MudConnection

Docker run connection.

Spins up a new container via docker run for each connection.

Source code in src/mudgym/connections/docker_run.py
class DockerRunConnection(MudConnection):
    """
    Docker run connection.

    Spins up a new container via `docker run` for each connection.
    """

    initial_prompt: PromptSpec = Prompt.OPTION

    def __init__(
        self,
        container_name: str | None = None,
        image_name: str = DOCKER_IMAGE,
        use_tty: bool = True,
        *,
        account_id: str = DEFAULT_ACCOUNT_ID,
        password: str = DEFAULT_PASSWORD,
        persona_slot: int | None = None,
        db_slot: int | None = None,
        name_generator: Callable[[], str] | None = None,
    ):
        super().__init__(
            account_id=account_id,
            password=password,
            persona_slot=persona_slot,
            db_slot=db_slot,
            name_generator=name_generator,
        )

        # generate unique container name if not provided
        self.container_name = container_name or f"{CONTAINER_PREFIX}_{uuid.uuid4().hex}"
        self.image_name = image_name
        self.use_tty = use_tty

        self.command = self.build_command()

    def build_command(self) -> list[str]:
        return [
            "docker",
            "run",
            "--name",
            self.container_name,
            "--init",
            "--rm",
            "-it" if self.use_tty else "-i",
            "--ipc=private",
            "--shm-size=100mb",
            "-e",
            f"L0={self.account_id}",
            "-e",
            f"L1={self.password}",
            self.image_name,
        ]

    def cleanup_container(self) -> None:
        """Remove any existing container with our name to avoid conflicts.

        This handles the case where a previous container wasn't properly cleaned up,
        e.g., if the process was killed abruptly or marimo re-ran a cell.
        """
        try:
            subprocess.run(
                ["docker", "rm", "-f", self.container_name],
                capture_output=True,
                check=False,  # Don't raise if container doesn't exist
            )
            logger.debug("docker.container.cleanup", container_name=self.container_name)
        except Exception as e:
            logger.debug("docker.container.cleanup.failed", container_name=self.container_name, error=str(e))

    def spawn(self) -> pexpect.spawn:
        """Spawn the Docker container, ensuring any stale container is cleaned up first."""
        ensure_docker_image(self.image_name)
        self.cleanup_container()
        return super().spawn()

cleanup_container()

Remove any existing container with our name to avoid conflicts.

This handles the case where a previous container wasn't properly cleaned up, e.g., if the process was killed abruptly or marimo re-ran a cell.

Source code in src/mudgym/connections/docker_run.py
def cleanup_container(self) -> None:
    """Remove any existing container with our name to avoid conflicts.

    This handles the case where a previous container wasn't properly cleaned up,
    e.g., if the process was killed abruptly or marimo re-ran a cell.
    """
    try:
        subprocess.run(
            ["docker", "rm", "-f", self.container_name],
            capture_output=True,
            check=False,  # Don't raise if container doesn't exist
        )
        logger.debug("docker.container.cleanup", container_name=self.container_name)
    except Exception as e:
        logger.debug("docker.container.cleanup.failed", container_name=self.container_name, error=str(e))

spawn()

Spawn the Docker container, ensuring any stale container is cleaned up first.

Source code in src/mudgym/connections/docker_run.py
def spawn(self) -> pexpect.spawn:
    """Spawn the Docker container, ensuring any stale container is cleaned up first."""
    ensure_docker_image(self.image_name)
    self.cleanup_container()
    return super().spawn()

mudgym.connections.docker_exec.DockerExecConnection

Bases: MudConnection

MUD2 connection that execs into an existing Docker container.

Typically for using a single container running with multiple game slots, or when connecting multiple clients to the same game slot.

Source code in src/mudgym/connections/docker_exec.py
class DockerExecConnection(MudConnection):
    """
    MUD2 connection that execs into an existing Docker container.

    Typically for using a single container running with multiple game slots, or when connecting
    multiple clients to the same game slot.
    """

    initial_prompt: PromptSpec = [
        Prompt.OPTION,
        Prompt.SUPERSEDE,
        Prompt.SESSION_DYING,
    ]

    def __init__(
        self,
        container_name: str | None = None,
        container_id: str | None = None,
        start_if_missing: bool = True,
        container_image: str = DOCKER_IMAGE,
        *,
        use_tty: bool = True,
        account_id: str = DEFAULT_ACCOUNT_ID,
        password: str = DEFAULT_PASSWORD,
        persona_slot: int | None = None,
        db_slot: int | None = None,
        name_generator: Callable[[], str] | None = None,
    ):
        container_name = container_name if container_name is not None else configured_docker_exec_container_name()
        # if container_id is not provided, find the first container with the given prefix
        self.container_image = container_image
        self.container_name = container_name
        # track whether we started the container and own the lifecycle
        self._started_container = False
        self.container_id = container_id or self.find_container_id(container_name, start_if_missing)
        self.use_tty = use_tty

        super().__init__(
            account_id=account_id,
            password=password,
            persona_slot=persona_slot,
            db_slot=db_slot,
            name_generator=name_generator,
        )
        self.command = self.build_command()

    def find_container_id(self, name: str, start_if_missing: bool = True) -> str:
        """Find container ID by name."""
        out = subprocess.check_output(["docker", "ps", "-qf", f"name={name}"], text=True).strip()
        if out:
            # if multiple lines, pick first:
            return out.splitlines()[0]
        if start_if_missing:
            logger.debug("docker.container.not_found", name=name, start_if_missing=start_if_missing)
            return self.start_container()
        raise RuntimeError(f"No running container matches name={name}; start_if_missing={start_if_missing}")

    def start_container(self) -> str:
        """Start a new container and return the container ID."""
        ensure_docker_image(self.container_image)
        output = subprocess.check_output(
            [
                "docker",
                "run",
                "--init",
                "--rm",
                "-d",
                "--name",
                self.container_name,
                self.container_image,
                "/bin/sh",
                "-lc",
                "/app/bin/boot -n 1 -f -k",
            ],
            text=True,
        ).strip()

        # docker run -d prints the container ID on stdout
        container_id = output.splitlines()[-1] if output else ""
        if not container_id:
            raise RuntimeError(f"docker run did not return a container id for {self.container_name}")

        logger.debug("docker.container.started", container_id=container_id)
        self.container_id = container_id
        self._started_container = True
        return self.container_id

    def build_command(self) -> list[str]:
        cmd = [
            "docker",
            "exec",
        ]

        # only add TTY flag if we have a TTY (needed for GitHub Actions)
        if self.use_tty:
            cmd.append("-it")
        else:
            cmd.append("-i")

        cmd.extend(
            [
                "-e",
                f"L0={self.account_id}",
                "-e",
                f"L1={self.password}",
            ]
        )

        cmd.extend([self.container_id, "/app/bin/mudlogin", "-n"])

        return cmd

    def close(self):
        """
        Close the exec session, and remove the container if this connection started it.

        We only tear down the container we own (one we started because none was running). A
        pre-existing, shared container is left alone so other clients exec'd into it survive.
        """
        try:
            super().close()
        finally:
            if self._started_container:
                try:
                    subprocess.run(
                        ["docker", "rm", "-f", self.container_name],
                        capture_output=True,
                        check=False,  # Don't raise if the container is already gone
                    )
                    logger.debug("docker.container.removed", container_name=self.container_name)
                except Exception as e:
                    logger.debug("docker.container.remove.failed", container_name=self.container_name, error=str(e))
                finally:
                    self._started_container = False

find_container_id(name, start_if_missing=True)

Find container ID by name.

Source code in src/mudgym/connections/docker_exec.py
def find_container_id(self, name: str, start_if_missing: bool = True) -> str:
    """Find container ID by name."""
    out = subprocess.check_output(["docker", "ps", "-qf", f"name={name}"], text=True).strip()
    if out:
        # if multiple lines, pick first:
        return out.splitlines()[0]
    if start_if_missing:
        logger.debug("docker.container.not_found", name=name, start_if_missing=start_if_missing)
        return self.start_container()
    raise RuntimeError(f"No running container matches name={name}; start_if_missing={start_if_missing}")

start_container()

Start a new container and return the container ID.

Source code in src/mudgym/connections/docker_exec.py
def start_container(self) -> str:
    """Start a new container and return the container ID."""
    ensure_docker_image(self.container_image)
    output = subprocess.check_output(
        [
            "docker",
            "run",
            "--init",
            "--rm",
            "-d",
            "--name",
            self.container_name,
            self.container_image,
            "/bin/sh",
            "-lc",
            "/app/bin/boot -n 1 -f -k",
        ],
        text=True,
    ).strip()

    # docker run -d prints the container ID on stdout
    container_id = output.splitlines()[-1] if output else ""
    if not container_id:
        raise RuntimeError(f"docker run did not return a container id for {self.container_name}")

    logger.debug("docker.container.started", container_id=container_id)
    self.container_id = container_id
    self._started_container = True
    return self.container_id

close()

Close the exec session, and remove the container if this connection started it.

We only tear down the container we own (one we started because none was running). A pre-existing, shared container is left alone so other clients exec'd into it survive.

Source code in src/mudgym/connections/docker_exec.py
def close(self):
    """
    Close the exec session, and remove the container if this connection started it.

    We only tear down the container we own (one we started because none was running). A
    pre-existing, shared container is left alone so other clients exec'd into it survive.
    """
    try:
        super().close()
    finally:
        if self._started_container:
            try:
                subprocess.run(
                    ["docker", "rm", "-f", self.container_name],
                    capture_output=True,
                    check=False,  # Don't raise if the container is already gone
                )
                logger.debug("docker.container.removed", container_name=self.container_name)
            except Exception as e:
                logger.debug("docker.container.remove.failed", container_name=self.container_name, error=str(e))
            finally:
                self._started_container = False

mudgym.connections.provider.ConnectionProvider

Bases: Protocol

Provides batches of connections backed by some shared set of resources.

The provider decides what those connections are connected to. That might be one world per connection, several players sharing a world, or something else entirely - the vector env doesn't need to know.

Once create_connections returns, the caller owns the connections. If it fails before returning, the provider cleans up whatever that call managed to create. The provider itself stays around until the owning environment closes, as it may also own containers or other resources the connections depend on.

Source code in src/mudgym/connections/provider.py
class ConnectionProvider(Protocol):
    """Provides batches of connections backed by some shared set of resources.

    The provider decides what those connections are connected to. That might be one world per
    connection, several players sharing a world, or something else entirely - the vector env doesn't need to
    know.

    Once ``create_connections`` returns, the caller owns the connections. If it fails before returning, the
    provider cleans up whatever that call managed to create. The provider itself stays around until the owning
    environment closes, as it may also own containers or other resources the connections depend on.
    """

    def create_connections(self, count: int) -> list[MudConnection]:
        """Create exactly ``count`` connections, cleaning up this call if it fails."""
        ...

    def reset(self, *, seed: int | list[int | None] | None = None) -> None:
        """Reset managed resources, interpreting seeds according to the provider's topology."""
        ...

    def close(self) -> None:
        """Close the provider and clean up shared infrastructure."""
        ...

create_connections(count)

Create exactly count connections, cleaning up this call if it fails.

Source code in src/mudgym/connections/provider.py
def create_connections(self, count: int) -> list[MudConnection]:
    """Create exactly ``count`` connections, cleaning up this call if it fails."""
    ...

reset(*, seed=None)

Reset managed resources, interpreting seeds according to the provider's topology.

Source code in src/mudgym/connections/provider.py
def reset(self, *, seed: int | list[int | None] | None = None) -> None:
    """Reset managed resources, interpreting seeds according to the provider's topology."""
    ...

close()

Close the provider and clean up shared infrastructure.

Source code in src/mudgym/connections/provider.py
def close(self) -> None:
    """Close the provider and clean up shared infrastructure."""
    ...

mudgym.connections.provider.DockerExecProvider

Bases: ConnectionProvider

Provides one fixed batch of connections backed by Docker worlds.

worlds describes the topology, while the count passed to create_connections is simply how many connections the caller needs. If worlds is omitted we use one world per connection. Docker needs the whole count up front to size its containers, so this particular provider only supplies one batch.

Source code in src/mudgym/connections/provider.py
class DockerExecProvider(ConnectionProvider):
    """Provides one fixed batch of connections backed by Docker worlds.

    ``worlds`` describes the topology, while the count passed to ``create_connections`` is simply how many connections the caller needs. If ``worlds`` is omitted we use one world per connection. Docker needs the whole count up front to size its containers, so this particular provider only supplies one batch.
    """

    def __init__(
        self,
        worlds: int | None = None,
        image: str = DOCKER_IMAGE,
        container_id: str | None = None,
        worlds_per_container: int = 128,
        *,
        connection_class: type[MudConnection] = DockerExecConnection,
        connection_kwargs: dict | None = None,
    ):
        if worlds is not None and worlds < 1:
            raise ValueError("worlds must be at least 1.")
        if worlds_per_container < 1:
            raise ValueError("worlds_per_container must be at least 1.")
        self.worlds = worlds
        self.image = image
        self.container_id = container_id
        self.worlds_per_container = worlds_per_container
        self.connection_class = connection_class
        self.connection_kwargs = dict(connection_kwargs or {})

        reserved = {"account_id", "db_slot", "container_id"}
        bad = reserved & set(self.connection_kwargs)
        if bad:
            raise ValueError(f"connection_kwargs must not set provider-managed keys: {sorted(bad)}")

        self._lock = Lock()
        self._closed = False
        self._batch_created = False
        self._owner_pid = os.getpid()

        self.containers: list[str] = []
        # A supplied container belongs to its caller. Containers we start ourselves belong to us.
        self.owns_containers = container_id is None

    def __getstate__(self):
        state = self.__dict__.copy()
        # Locks cannot be pickled, so an unpickled provider gets a fresh one below.
        state.pop("_lock", None)
        # Once a batch exists, a pickled copy only refers to the allocating process's containers.
        # It must not decide they are now its containers and stop them on close.
        if self._batch_created:
            state["owns_containers"] = False
        return state

    def __setstate__(self, state):
        self.__dict__.update(state)
        self._lock = Lock()
        # Before allocation there is nothing to inherit. This process owns anything it starts later.
        if not self._batch_created:
            self._owner_pid = os.getpid()

    def prepare_shared_container(self, slots: int) -> str:
        """Start a new container with multiple game slots."""
        unique = uuid4().hex[:6]
        container_name = f"mud_shared_{int(time.time())}_{slots}_{unique}"

        logger.info("provider.container.starting", container_name=container_name, slots=slots)

        # boot prepares the worlds and exits, so sleep keeps the container around for docker exec.
        result = subprocess.run(
            [
                "docker",
                "run",
                "-d",
                "--init",
                "--rm",
                "--ipc",
                "private",
                "--pids-limit",
                "4096",
                "--log-driver",
                "none",
                "--name",
                container_name,
                self.image,
                "/bin/sh",
                "-c",
                f"/app/bin/boot -n {slots} -f -k && sleep infinity",
            ],
            capture_output=True,
            text=True,
        )

        if result.returncode != 0:
            raise RuntimeError(f"Failed to start container: {result.stderr}")

        container_id = result.stdout.strip()
        logger.info("provider.container.started", container_id=container_id)
        return container_id

    def create_connections(self, count: int) -> list[MudConnection]:
        """Start enough Docker worlds and create this provider's one connection batch."""
        if count < 1:
            raise ValueError("count must be at least 1.")

        connections: list[MudConnection] = []
        allocation_started = False
        try:
            with self._lock:
                if self._closed:
                    raise RuntimeError("Provider is closed.")
                if self._batch_created:
                    raise RuntimeError("Provider has already created its connection batch.")
                self._batch_created = True
                allocation_started = True
                if self.owns_containers:
                    # Allocation is lazy, so the process doing this work owns the containers. It
                    # may not be the process that originally constructed or pickled the provider.
                    self._owner_pid = os.getpid()

                world_count = self.worlds if self.worlds is not None else count
                if self.container_id:
                    if world_count > self.worlds_per_container:
                        raise ValueError("Single container mode only supports up to 'worlds_per_container' worlds")
                    self.containers.append(self.container_id)
                else:
                    starts = range(0, world_count, self.worlds_per_container)
                    total = (world_count + self.worlds_per_container - 1) // self.worlds_per_container
                    for index, start in enumerate(starts):
                        slots = min(self.worlds_per_container, world_count - start)
                        logger.info(
                            "provider.container.launching",
                            idx=index + 1,
                            total=total,
                            slots=slots,
                            start=start,
                        )
                        self.containers.append(self.prepare_shared_container(slots))

                for env_index in range(count):
                    # Connections wrap around the configured worlds, which is how several players
                    # can share one. ``world_index`` is provider-wide; ``db_slot`` is the historical
                    # MUD2 name for the index inside one container. The distinction only matters
                    # once we need more worlds than one MUD2 process can hold (a problem the
                    # original authors can probably be forgiven for not anticipating).
                    world_index = env_index % world_count
                    container_index, db_slot = divmod(world_index, self.worlds_per_container)

                    kwargs = dict(self.connection_kwargs)
                    kwargs["account_id"] = f"W{env_index + 1:08d}"
                    kwargs["db_slot"] = db_slot
                    connections.append(
                        self.connection_class(
                            container_id=self.containers[container_index],
                            **kwargs,
                        )
                    )

            return connections
        except BaseException:
            # A failure after allocation starts belongs to us: close the connections we did make,
            # then the containers beneath them. Errors here must not replace the original failure.
            # A rejected second call never started an allocation, so it leaves the first batch alone.
            if not allocation_started:
                raise
            for connection in connections:
                with suppress(Exception):
                    connection.close()
            try:
                self.close()
            except Exception:
                logger.error("provider.batch_cleanup_failed", exc_info=True)
            raise

    def reset(self, *, seed: int | list[int | None] | None = None) -> None:
        """Docker worlds currently retain their running state across environment resets."""

    def close(self) -> None:
        """Close the shared infrastructure, but not the connections owned by environments."""
        with self._lock:
            if self._closed:
                return
            self._closed = True
            to_stop = list(self.containers) if self.owns_containers else []
            self.containers.clear()

        # Forked and spawned copies can close their references, but only the allocating process
        # gets to stop the actual containers.
        if os.getpid() != self._owner_pid:
            logger.debug("provider.container.close.skip_non_owner", pid=os.getpid(), owner_pid=self._owner_pid)
            return

        first_exc = None
        for container_id in to_stop:
            try:
                subprocess.run(["docker", "stop", container_id], check=True, capture_output=True)
            except Exception as exc:
                logger.error(
                    "provider.container.close.stop_failed",
                    container_id=container_id,
                    pid=os.getpid(),
                    owner_pid=self._owner_pid,
                    exc_info=True,
                )
                if first_exc is None:
                    first_exc = exc

        if first_exc is not None:
            raise first_exc

prepare_shared_container(slots)

Start a new container with multiple game slots.

Source code in src/mudgym/connections/provider.py
def prepare_shared_container(self, slots: int) -> str:
    """Start a new container with multiple game slots."""
    unique = uuid4().hex[:6]
    container_name = f"mud_shared_{int(time.time())}_{slots}_{unique}"

    logger.info("provider.container.starting", container_name=container_name, slots=slots)

    # boot prepares the worlds and exits, so sleep keeps the container around for docker exec.
    result = subprocess.run(
        [
            "docker",
            "run",
            "-d",
            "--init",
            "--rm",
            "--ipc",
            "private",
            "--pids-limit",
            "4096",
            "--log-driver",
            "none",
            "--name",
            container_name,
            self.image,
            "/bin/sh",
            "-c",
            f"/app/bin/boot -n {slots} -f -k && sleep infinity",
        ],
        capture_output=True,
        text=True,
    )

    if result.returncode != 0:
        raise RuntimeError(f"Failed to start container: {result.stderr}")

    container_id = result.stdout.strip()
    logger.info("provider.container.started", container_id=container_id)
    return container_id

create_connections(count)

Start enough Docker worlds and create this provider's one connection batch.

Source code in src/mudgym/connections/provider.py
def create_connections(self, count: int) -> list[MudConnection]:
    """Start enough Docker worlds and create this provider's one connection batch."""
    if count < 1:
        raise ValueError("count must be at least 1.")

    connections: list[MudConnection] = []
    allocation_started = False
    try:
        with self._lock:
            if self._closed:
                raise RuntimeError("Provider is closed.")
            if self._batch_created:
                raise RuntimeError("Provider has already created its connection batch.")
            self._batch_created = True
            allocation_started = True
            if self.owns_containers:
                # Allocation is lazy, so the process doing this work owns the containers. It
                # may not be the process that originally constructed or pickled the provider.
                self._owner_pid = os.getpid()

            world_count = self.worlds if self.worlds is not None else count
            if self.container_id:
                if world_count > self.worlds_per_container:
                    raise ValueError("Single container mode only supports up to 'worlds_per_container' worlds")
                self.containers.append(self.container_id)
            else:
                starts = range(0, world_count, self.worlds_per_container)
                total = (world_count + self.worlds_per_container - 1) // self.worlds_per_container
                for index, start in enumerate(starts):
                    slots = min(self.worlds_per_container, world_count - start)
                    logger.info(
                        "provider.container.launching",
                        idx=index + 1,
                        total=total,
                        slots=slots,
                        start=start,
                    )
                    self.containers.append(self.prepare_shared_container(slots))

            for env_index in range(count):
                # Connections wrap around the configured worlds, which is how several players
                # can share one. ``world_index`` is provider-wide; ``db_slot`` is the historical
                # MUD2 name for the index inside one container. The distinction only matters
                # once we need more worlds than one MUD2 process can hold (a problem the
                # original authors can probably be forgiven for not anticipating).
                world_index = env_index % world_count
                container_index, db_slot = divmod(world_index, self.worlds_per_container)

                kwargs = dict(self.connection_kwargs)
                kwargs["account_id"] = f"W{env_index + 1:08d}"
                kwargs["db_slot"] = db_slot
                connections.append(
                    self.connection_class(
                        container_id=self.containers[container_index],
                        **kwargs,
                    )
                )

        return connections
    except BaseException:
        # A failure after allocation starts belongs to us: close the connections we did make,
        # then the containers beneath them. Errors here must not replace the original failure.
        # A rejected second call never started an allocation, so it leaves the first batch alone.
        if not allocation_started:
            raise
        for connection in connections:
            with suppress(Exception):
                connection.close()
        try:
            self.close()
        except Exception:
            logger.error("provider.batch_cleanup_failed", exc_info=True)
        raise

reset(*, seed=None)

Docker worlds currently retain their running state across environment resets.

Source code in src/mudgym/connections/provider.py
def reset(self, *, seed: int | list[int | None] | None = None) -> None:
    """Docker worlds currently retain their running state across environment resets."""

close()

Close the shared infrastructure, but not the connections owned by environments.

Source code in src/mudgym/connections/provider.py
def close(self) -> None:
    """Close the shared infrastructure, but not the connections owned by environments."""
    with self._lock:
        if self._closed:
            return
        self._closed = True
        to_stop = list(self.containers) if self.owns_containers else []
        self.containers.clear()

    # Forked and spawned copies can close their references, but only the allocating process
    # gets to stop the actual containers.
    if os.getpid() != self._owner_pid:
        logger.debug("provider.container.close.skip_non_owner", pid=os.getpid(), owner_pid=self._owner_pid)
        return

    first_exc = None
    for container_id in to_stop:
        try:
            subprocess.run(["docker", "stop", container_id], check=True, capture_output=True)
        except Exception as exc:
            logger.error(
                "provider.container.close.stop_failed",
                container_id=container_id,
                pid=os.getpid(),
                owner_pid=self._owner_pid,
                exc_info=True,
            )
            if first_exc is None:
                first_exc = exc

    if first_exc is not None:
        raise first_exc