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, world_ticker=None, *, persona=None, sex=None, persona_pool=None)

Build a Gymnasium environment.

Reset and step expose info["persona"] and info["persona_sex"]. Non-None persona options override connection_kwargs. Configure existing connections on their provider. See make_parallel_env for per-player tuple shorthand.

world_ticker runs between the action and observation, defaulting to the connection's tick_for_step hook if present.

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,
    world_ticker: Callable[[], None] | None = None,
    *,
    persona: str | None = None,
    sex: str | None = None,
    persona_pool: Sequence[tuple[str, str | None]] | None = None,
) -> gym.Env:
    """Build a Gymnasium environment.

    Reset and step expose ``info["persona"]`` and ``info["persona_sex"]``. Non-None persona options override ``connection_kwargs``. Configure existing connections on their provider. See ``make_parallel_env`` for per-player tuple shorthand.

    ``world_ticker`` runs between the action and observation, defaulting to the connection's ``tick_for_step`` hook if present.
    """
    action_wrapper = {"text": None, "directions": DiscreteDirectionsWrapper}[actions]
    fields = OBSERVATION_PRESETS[observation] if field_parsers is None else tuple(field_parsers)
    options = {"persona": persona, "sex": sex, "persona_pool": persona_pool}
    connection_kwargs = {
        **(connection_kwargs or {}),
        **{key: value for key, value in options.items() if value is not None},
    }
    if isinstance(connection, MudConnection) and connection_kwargs:
        raise ValueError("Configure connection options on the provider when passing an explicit connection instance.")
    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(**connection_kwargs)
    )
    if world_ticker is None:
        world_ticker = getattr(resolved_connection, "tick_for_step", None)

    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,
            world_ticker=world_ticker,
        )
    except BaseException:
        close_quietly(resolved_connection)
        raise
    try:
        return env if action_wrapper is None else action_wrapper(env)
    except BaseException:
        close_quietly(env)
        raise

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

Create a PettingZoo environment whose players share one MUD world.

world_ticker runs after all actions and before any observations, defaulting to the provider's tick_for_step hook if present.

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,
    world_ticker: Callable[[], None] | None = None,
    personas: Sequence[tuple[str] | tuple[str | None, str | None]] | None = None,
    persona_pool: Sequence[tuple[str, str | None]] | None = None,
) -> ParallelEnv:
    """Create a PettingZoo environment whose players share one MUD world.

    ``world_ticker`` runs after all actions and before any observations, defaulting to the provider's ``tick_for_step`` hook if present.
    """
    action_wrapper = {"text": None, "directions": ParallelDiscreteDirectionsWrapper}[actions]
    fields = OBSERVATION_PRESETS[observation] if field_parsers is None else tuple(field_parsers)
    child_render_mode = "ansi" if render_mode is not None else None

    if provider is not None and (personas is not None or persona_pool is not None):
        raise ValueError("Configure personas and persona_pool on the explicit provider.")
    if provider is None:
        options = {"personas": personas, "persona_pool": persona_pool}
        provider = registry.default_parallel_provider_factory(
            **{key: value for key, value in options.items() if value is not None}
        )
    if world_ticker is None:
        world_ticker = getattr(provider, "tick_for_step", None)
    children = {
        f"player_{index}": child
        for index, child in enumerate(
            create_players(
                agents,
                provider,
                fields,
                child_render_mode,
                tearoom_commands,
                require_shared_world=True,
            )
        )
    }
    try:
        base_env = MudParallelEnv(
            children,
            provider=provider,
            render_mode=render_mode,
            world_ticker=world_ticker,
        )
        return base_env if action_wrapper is None else action_wrapper(base_env)
    except BaseException:
        close_quietly(*children.values(), 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: dict[str, Any] = {  # noqa: RUF012 - Gymnasium permits instance metadata overrides
        "render_modes": ["human", "ansi"],
    }

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

        self.world_ticker = world_ticker

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

        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),
            "points": gym.spaces.Box(low=0, high=WIZARD_POINTS, shape=(), dtype=INT_DTYPE),
        }
        empty_observation: dict[str, Any] = {"text": "", "points": INT_DTYPE(0)}
        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)

        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.persona: str | None = None
        self.points: int | None = None

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

    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) or [raw_bytes]
        # anything that came from the game before our echo we don't try and parse into observation fields
        pre_echo_render_chunks = [
            chunk for segment in segments[:-1] for chunk in split_on_prompt(segment, preserve_ansi=True)
        ]
        chunks = split_on_prompt(segments[-1])
        render_chunks = split_on_prompt(segments[-1], preserve_ansi=True)

        # 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 chunk, render_chunk in zip(chunks, render_chunks, strict=True):
                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(render_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(render_chunk)
                    pending_fields.pop(0)
                else:
                    payload_text_chunks.append(render_chunk)
            if pending_fields:
                raise RuntimeError(
                    f"response completed but fields {[f.__class__.__name__ for f in pending_fields]} "
                    f"found no matching response among {len(chunks)} window chunks"
                )

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

        # keeps the game's ANSI colour - text observation space doesn't.
        render_payload = b"\n".join(text_chunks)
        render_payload = DATABASE_BROADCAST_RE.sub(b"", render_payload)
        render_bytes = normalise_lines(render_payload)
        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]
        if self.points is not None:
            obs["points"] = INT_DTYPE(self.points)
        return obs, render_bytes, field_refusals

    def clean_tearoom_exit(self, raw_bytes: bytes) -> bytes:
        """Drop the tearoom setup through the exit narration."""
        narration_end = TEAROOM_EXIT_NARRATION_END.search(raw_bytes)
        if narration_end is None:
            raise ValueError(f"tearoom exit marker {TEAROOM_EXIT_NARRATION_END.pattern!r} not found in: {raw_bytes!r}")
        return raw_bytes[narration_end.end() :]

    def update_points(self, raw_bytes: bytes, *, terminated: bool = False) -> int | None:
        """Update the tracked score from points events or permadeath."""
        # Numeric events require colours a player cannot forge through the command echo.
        points = parse_points_changes(raw_bytes)["points"]
        if terminated and is_permadeath(raw_bytes):
            points = 0
        if points is not None:
            self.points = points = min(points, WIZARD_POINTS)
        return points

    def make_info(
        self,
        *,
        raw_bytes: bytes,
        render_bytes: bytes,
        field_refusals: dict[str, bytes],
        transport: dict[str, Any],
    ) -> dict[str, Any]:
        info: dict[str, Any] = {
            "raw_bytes": raw_bytes,
            "render_bytes": render_bytes,
            "step": self.step_count,
            "persona": self.persona,
            "action_rejected": command_rejected(raw_bytes, transport["sent_lines"]),
        }
        if field_refusals:
            info["field_refusals"] = field_refusals
        if persona := transport.get("persona"):
            info["persona_sex"] = persona["sex"]
        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 _prepare_reset(self, *, seed: int | None = None, options: dict | None = None) -> None:
        """Seed and prepare the player in the tearoom, consuming all setup responses."""
        super().reset(seed=seed, options=options)
        if seed is not None:
            self.action_space.seed(seed)
        self.step_count = 0
        self.last_render_bytes = b""
        self.persona, self.points = self.session.reset(seed=seed)

        if self.tearoom_commands:
            raw_bytes, terminated, incomplete, transport = self.session.command(self.tearoom_commands)
            self.update_points(raw_bytes, terminated=terminated)
            if terminated or incomplete:
                raise RuntimeError(
                    f"tearoom commands {self.tearoom_commands!r} failed during reset "
                    f"(terminated={terminated}, incomplete={incomplete}) "
                    f"raw_bytes={raw_bytes!r}, transport={transport!r}"
                )

    def _enter_world(self) -> bytes:
        """Complete entry and return the retained room bytes."""
        self.session.send("move north")
        raw_bytes, terminated, incomplete, transport = self.session.read_pending_response()
        self.update_points(raw_bytes, terminated=terminated)
        if terminated or incomplete or self.points == WIZARD_POINTS:
            raise RuntimeError(
                f"step out of the tearoom failed during reset "
                f"(terminated={terminated}, incomplete={incomplete}, points={self.points}) "
                f"raw_bytes={raw_bytes!r}, transport={transport!r}"
            )
        try:
            return self.clean_tearoom_exit(raw_bytes)
        except ValueError as error:
            error.add_note(f"entry transport={transport!r}")
            raise

    def _finish_reset(self, entry_bytes: bytes) -> tuple[dict[str, Any], dict[str, Any]]:
        """Collect final fields and assemble the initial observation once all selected players have entered."""
        observation_bytes, terminated, incomplete, transport = self.session.receive()
        self.update_points(observation_bytes, terminated=terminated)
        raw_bytes = entry_bytes + observation_bytes
        if terminated or incomplete or self.points == WIZARD_POINTS:
            raise RuntimeError(
                f"initial observation failed during reset "
                f"(terminated={terminated}, incomplete={incomplete}, points={self.points}) "
                f"raw_bytes={raw_bytes!r}, transport={transport!r}"
            )
        transport = {**transport, "bytes_length": len(raw_bytes)}
        try:
            observation, render_bytes, field_refusals = self.bytes_to_observation(
                raw_bytes,
                sent_lines=transport["sent_lines"],
                response_complete=True,
            )
        except Exception as error:
            error.add_note(f"reset raw_bytes={raw_bytes!r}, transport={transport!r}")
            raise
        info = self.make_info(
            raw_bytes=raw_bytes,
            render_bytes=render_bytes,
            field_refusals=field_refusals,
            transport=transport,
        )
        info["transport"] = {**transport, "incomplete": incomplete}
        self.last_render_bytes = render_bytes
        return observation, info

    def _invalidate_episode(self, error: BaseException) -> None:
        """Require a new reset, preserving the original failure and recording cleanup interruptions as notes."""
        self.points = None
        self.last_render_bytes = b""
        try:
            self.session.connection.invalidate()
        except BaseException as cleanup_error:  # noqa: BLE001 - attach cleanup failures to the original error
            error.add_note(f"episode invalidation failed for persona {self.persona!r}: {cleanup_error!r}")

    def reset(
        self,
        *,
        seed: int | None = None,
        options: dict | None = None,
    ) -> tuple[dict[str, Any], dict[str, Any]]:
        """Prepare, enter and collect the initial observation without advancing the world clock."""
        phase = "preparation"
        try:
            self._prepare_reset(seed=seed, options=options)
            phase = "entry"
            entry_bytes = self._enter_world()
            phase = "observation"
            observation, info = self._finish_reset(entry_bytes)
        except BaseException as error:
            error.add_note(f"reset failed during {phase} for persona {self.persona!r}")
            self._invalidate_episode(error)
            raise
        if self.render_mode == "human":
            self.render()
        return observation, info

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

        ``world_ticker`` runs once after the action and before its observation, so a standalone env advances its own
        world here. Vector and parallel coordinators drive ``act()`` and ``observe()`` themselves and own the joint
        advancement, so their children are built without one.
        """
        self.act(action)
        if self.world_ticker is not None:
            self.world_ticker()
        return self.observe()

    def validate_action(self, action: str) -> None:
        """Validate an action without changing player or session state."""
        if not self.action_space.contains(action):
            raise ValueError(f"Invalid action {action!r}. Expected {self.action_space}.")

    def act(self, action: str) -> None:
        """Send an action now, leaving its observation for a later ``observe`` call."""
        self.validate_action(action)
        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 through this player's completed response.

        This includes the earlier action, the observation-command responses, and anything caused by other players since
        that action was sent.
        """
        points_before_step = self.points
        if points_before_step is None:
            raise RuntimeError("step called before reset established the persona score")
        # The response is consumed and the game has moved on, so a failure here loses this transition.
        try:
            return self._receive_transition(points_before_step)
        except BaseException as error:
            error.add_note(f"step {self.step_count} failed for persona {self.persona!r}, reset required")
            self._invalidate_episode(error)
            raise

    def _receive_transition(self, points_before_step: int) -> tuple[dict[str, Any], float, bool, bool, dict[str, Any]]:
        """Read the pending response and build its transition."""
        raw_bytes, terminated, incomplete, debug_info = self.session.receive()
        response_complete = not (terminated or incomplete)
        truncated = incomplete
        event_points = self.update_points(raw_bytes, terminated=terminated)
        if event_points == WIZARD_POINTS:
            # The container saves this score and closes before the observation command can run.
            if not terminated and not incomplete:
                self.session.connection.invalidate()
            terminated, truncated = True, False
        reward = float(self.points - points_before_step)

        obs, render_bytes, field_refusals = self.bytes_to_observation(
            raw_bytes,
            sent_lines=debug_info["sent_lines"],
            response_complete=response_complete,
        )
        self.last_render_bytes = render_bytes
        info = self.make_info(
            raw_bytes=raw_bytes,
            render_bytes=render_bytes,
            field_refusals=field_refusals,
            transport=debug_info,
        )
        info["transport"] = {**debug_info, "incomplete": incomplete}

        if event_points is not None:
            info["points"] = event_points

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

        return obs, reward, terminated, truncated, info

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

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) or [raw_bytes]
    # anything that came from the game before our echo we don't try and parse into observation fields
    pre_echo_render_chunks = [
        chunk for segment in segments[:-1] for chunk in split_on_prompt(segment, preserve_ansi=True)
    ]
    chunks = split_on_prompt(segments[-1])
    render_chunks = split_on_prompt(segments[-1], preserve_ansi=True)

    # 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 chunk, render_chunk in zip(chunks, render_chunks, strict=True):
            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(render_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(render_chunk)
                pending_fields.pop(0)
            else:
                payload_text_chunks.append(render_chunk)
        if pending_fields:
            raise RuntimeError(
                f"response completed but fields {[f.__class__.__name__ for f in pending_fields]} "
                f"found no matching response among {len(chunks)} window chunks"
            )

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

    # keeps the game's ANSI colour - text observation space doesn't.
    render_payload = b"\n".join(text_chunks)
    render_payload = DATABASE_BROADCAST_RE.sub(b"", render_payload)
    render_bytes = normalise_lines(render_payload)
    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]
    if self.points is not None:
        obs["points"] = INT_DTYPE(self.points)
    return obs, render_bytes, field_refusals

clean_tearoom_exit(raw_bytes)

Drop the tearoom setup through the exit narration.

Source code in src/mudgym/envs/env.py
def clean_tearoom_exit(self, raw_bytes: bytes) -> bytes:
    """Drop the tearoom setup through the exit narration."""
    narration_end = TEAROOM_EXIT_NARRATION_END.search(raw_bytes)
    if narration_end is None:
        raise ValueError(f"tearoom exit marker {TEAROOM_EXIT_NARRATION_END.pattern!r} not found in: {raw_bytes!r}")
    return raw_bytes[narration_end.end() :]

update_points(raw_bytes, *, terminated=False)

Update the tracked score from points events or permadeath.

Source code in src/mudgym/envs/env.py
def update_points(self, raw_bytes: bytes, *, terminated: bool = False) -> int | None:
    """Update the tracked score from points events or permadeath."""
    # Numeric events require colours a player cannot forge through the command echo.
    points = parse_points_changes(raw_bytes)["points"]
    if terminated and is_permadeath(raw_bytes):
        points = 0
    if points is not None:
        self.points = points = min(points, WIZARD_POINTS)
    return points

reset(*, seed=None, options=None)

Prepare, enter and collect the initial observation without advancing the world clock.

Source code in src/mudgym/envs/env.py
def reset(
    self,
    *,
    seed: int | None = None,
    options: dict | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
    """Prepare, enter and collect the initial observation without advancing the world clock."""
    phase = "preparation"
    try:
        self._prepare_reset(seed=seed, options=options)
        phase = "entry"
        entry_bytes = self._enter_world()
        phase = "observation"
        observation, info = self._finish_reset(entry_bytes)
    except BaseException as error:
        error.add_note(f"reset failed during {phase} for persona {self.persona!r}")
        self._invalidate_episode(error)
        raise
    if self.render_mode == "human":
        self.render()
    return observation, info

step(action)

Send one action, then receive its completed observation.

world_ticker runs once after the action and before its observation, so a standalone env advances its own world here. Vector and parallel coordinators drive act() and observe() themselves and own the joint advancement, so their children are built without one.

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 completed observation.

    ``world_ticker`` runs once after the action and before its observation, so a standalone env advances its own
    world here. Vector and parallel coordinators drive ``act()`` and ``observe()`` themselves and own the joint
    advancement, so their children are built without one.
    """
    self.act(action)
    if self.world_ticker is not None:
        self.world_ticker()
    return self.observe()

validate_action(action)

Validate an action without changing player or session state.

Source code in src/mudgym/envs/env.py
def validate_action(self, action: str) -> None:
    """Validate an action without changing player or session state."""
    if not self.action_space.contains(action):
        raise ValueError(f"Invalid action {action!r}. Expected {self.action_space}.")

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."""
    self.validate_action(action)
    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 through this player's completed response.

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 through this player's completed response.

    This includes the earlier action, the observation-command responses, and anything caused by other players since
    that action was sent.
    """
    points_before_step = self.points
    if points_before_step is None:
        raise RuntimeError("step called before reset established the persona score")
    # The response is consumed and the game has moved on, so a failure here loses this transition.
    try:
        return self._receive_transition(points_before_step)
    except BaseException as error:
        error.add_note(f"step {self.step_count} failed for persona {self.persona!r}, reset required")
        self._invalidate_episode(error)
        raise

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: dict[str, Any] = {  # noqa: RUF012 - PettingZoo permits instance metadata overrides
        "render_modes": ["ansi", "human"],
        "name": "mud2_v0",
    }

    def __init__(
        self,
        envs: dict[str, MudEnv],
        provider: ConnectionProvider,
        render_mode: str | None = None,
        world_ticker: Callable[[], None] | 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.world_ticker = world_ticker

        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]]:
        agents = list(self.possible_agents)
        reset_worlds(self.envs, self._provider, seed)
        results = reset_players(
            self.envs,
            {agent: seed + index if seed is not None else None for index, agent in enumerate(agents)},
            options,
        )
        self.agents = agents
        obs = {agent: result[0] for agent, result in results.items()}
        infos = {agent: result[1] for agent, result in results.items()}

        return obs, 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],
    ]:
        obs = {}
        rewards = {}
        terminates = {}
        truncates = {}
        infos = {}

        agents = list(self.agents)
        if any(child.points is None for child in self.envs.values()):
            raise RuntimeError("Parallel environment requires a successful reset before stepping.")
        # Resolve required keys before sending so a missing action leaves sessions ready to retry.
        agent_actions = {agent: actions[agent] for agent in agents}
        for agent, result in step_players(self.envs, agent_actions, self.world_ticker):
            obs[agent], rewards[agent], terminates[agent], truncates[agent], infos[agent] = result

        # 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 terminates[agent] and not truncates[agent]]

        return obs, rewards, terminates, truncates, infos

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

        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)
        rendered = "".join(sections).rstrip("\n")
        if self.render_mode == "ansi":
            return rendered

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

    def close(self) -> None:
        close_players(self.envs, self._provider)

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 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[re.Pattern[bytes], ...] = (
        re.compile(rb"You can't wake yourself up yet!"),
        re.compile(rb"You can't see a thing, you're blind\."),
        # Waking up spends the command, so its output is only the wake-up and stamina lines.
        re.compile(rb"You wake up!\r?\nYour stamina is [0-9]+\."),
    )

    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 contributes no keys.
        """
        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."""
        if self.include_keys == ():
            return {}
        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, such as ``persona``. A parser consumes what it
        needs.
        """
        ...

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

        By default we return True. Subclasses can override this to make matching more robust, but should accept every
        output the command can produce, including when the persona is in the dark, asleep or blind.
        """
        return True

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

    def lines(self, raw_bytes: bytes) -> list[str]:
        """The chunk's text lines: ANSI stripped, decoded, split on line breaks and stripped of whitespace."""
        return decode_text_lines(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.lines(chunk):
                candidate = regex.match(line)
                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 contributes no keys.

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 contributes no keys.
    """
    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."""
    if self.include_keys == ():
        return {}
    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, such as persona. A parser consumes what it needs.

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, such as ``persona``. A parser consumes what it
    needs.
    """
    ...

matches(chunk)

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

By default we return True. Subclasses can override this to make matching more robust, but should accept every output the command can produce, including when the persona is in the dark, asleep or blind.

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 return True. Subclasses can override this to make matching more robust, but should accept every
    output the command can produce, including when the persona is in the dark, asleep or blind.
    """
    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."""
    response = strip_ansi(chunk).strip()
    return any(refusal.fullmatch(response) for refusal in self.PLAYER_STATE_REFUSALS)

lines(raw_bytes)

The chunk's text lines: ANSI stripped, decoded, split on line breaks and stripped of whitespace.

Source code in src/mudgym/envs/fields/field.py
def lines(self, raw_bytes: bytes) -> list[str]:
    """The chunk's text lines: ANSI stripped, decoded, split on line breaks and stripped of whitespace."""
    return decode_text_lines(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.lines(chunk):
            candidate = regex.match(line)
            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
  • 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:
      - 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+
        \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,
    )

    def full_space(self) -> dict[str, spaces.Space]:
        return {
            "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(WEATHER_COUNT + 1),
        }

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

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

    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[match.group("weather")]

        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 {
            "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[match.group("weather")]

    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 {
        "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 = self.lines(chunk)
        # 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"

    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.

Missing views use index 0. Supplied room names must be recognised.

Source code in src/mudgym/envs/fields/superquicklook.py
class SuperQuickLookField(ObservationField):
    """
    Parses room contents and inventory from the superquicklook command.

    Missing views use index 0. Supplied room names must be recognised.
    """

    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(ROOM_NAME_COUNT + 1),
            "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": UNKNOWN,
            "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_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)),
            "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_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)),
        "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.

Missing data uses index 0. Supplied room values must be recognised.

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.

    Missing data uses index 0. Supplied room values must be recognised.

    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"

    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(ROOM_ID_COUNT + 1),
            "room_name": spaces.Text(max_length=ROOM_NAME_MAX_LENGTH, min_length=0, charset=SINGLE_LINE_CHARSET),
            "room_name_index": spaces.Discrete(ROOM_NAME_COUNT + 1),
            "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": ITEM_SPACE,
        }

    def full_empty(self) -> dict[str, Any]:
        return {
            "room_id": UNKNOWN,
            "room_id_index": INDEX_DTYPE(0),
            "room_name": UNKNOWN,
            "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, str]:
        """Read the wire pairs without accepting duplicate keys."""
        values: dict[str, str] = {}
        for pair in decode_text_bytes(strip_ansi(payload_bytes)).split("; "):
            key, value = pair.split("=", 1)
            if key in values:
                raise ValueError(f"mgcheats block contains duplicate key {key!r}")
            values[key] = value
        return 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 = parsed["room_id"].lower()
        room_name = parsed["room_name"].lower()
        bits = {}
        for key in self.BIT_KEYS:
            value = parsed[key]
            if value not in ("0", "1"):
                raise ValueError(f"mgcheats {key}={value!r} is not a 0 or 1 flag")
            bits[key] = BIT_DTYPE(value)
        here = parsed["here"]
        if not here.startswith("[") or not here.endswith("]"):
            raise ValueError(f"mgcheats here={here!r} is not a bracketed list")
        inner = here[1:-1].lower()

        return {
            "room_id": room_id,
            "room_id_index": INDEX_DTYPE(room_id_to_index(room_id)),
            "room_name": room_name,
            "room_name_index": INDEX_DTYPE(room_name_to_index(room_name)),
            **bits,
            "here": tuple(inner.split(", ")) if inner else (),
        }

parse(payload_bytes)

Read the wire pairs without accepting duplicate keys.

Source code in src/mudgym/envs/fields/mgcheats.py
def parse(self, payload_bytes: bytes) -> dict[str, str]:
    """Read the wire pairs without accepting duplicate keys."""
    values: dict[str, str] = {}
    for pair in decode_text_bytes(strip_ansi(payload_bytes)).split("; "):
        key, value = pair.split("=", 1)
        if key in values:
            raise ValueError(f"mgcheats block contains duplicate key {key!r}")
        values[key] = value
    return 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 = parsed["room_id"].lower()
    room_name = parsed["room_name"].lower()
    bits = {}
    for key in self.BIT_KEYS:
        value = parsed[key]
        if value not in ("0", "1"):
            raise ValueError(f"mgcheats {key}={value!r} is not a 0 or 1 flag")
        bits[key] = BIT_DTYPE(value)
    here = parsed["here"]
    if not here.startswith("[") or not here.endswith("]"):
        raise ValueError(f"mgcheats here={here!r} is not a bracketed list")
    inner = here[1:-1].lower()

    return {
        "room_id": room_id,
        "room_id_index": INDEX_DTYPE(room_id_to_index(room_id)),
        "room_name": room_name,
        "room_name_index": INDEX_DTYPE(room_name_to_index(room_name)),
        **bits,
        "here": tuple(inner.split(", ")) if inner else (),
    }

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_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
        # 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

Bases: ABC

Connection interface for game sessions and recorded transcripts.

Reset prepares a session in the tearoom. Sending and reading are separate so several players can act before collecting their observations. Closing releases the connection's resources.

Source code in src/mudgym/connections/connection.py
class MudConnection(ABC):
    """Connection interface for game sessions and recorded transcripts.

    Reset prepares a session in the tearoom. Sending and reading are separate so several players can act before collecting their observations. Closing releases the connection's resources.
    """

    @abstractmethod
    def reset(self, *, seed: int | None = None) -> None:
        """Prepare the session in the tearoom for a new episode."""

    @abstractmethod
    def send_line(self, line: str) -> None:
        """Send a line without collecting its response."""

    @abstractmethod
    def read_response(self) -> tuple[bytes, bool, bool, dict[str, Any]]:
        """Collect pending output, game-over and incomplete flags, and transport details."""

    @abstractmethod
    def invalidate(self) -> None:
        """Discard a failed session so it requires a successful reset before reuse."""

    @abstractmethod
    def close(self) -> None:
        """Release the connection's resources."""

reset(*, seed=None) abstractmethod

Prepare the session in the tearoom for a new episode.

Source code in src/mudgym/connections/connection.py
@abstractmethod
def reset(self, *, seed: int | None = None) -> None:
    """Prepare the session in the tearoom for a new episode."""

send_line(line) abstractmethod

Send a line without collecting its response.

Source code in src/mudgym/connections/connection.py
@abstractmethod
def send_line(self, line: str) -> None:
    """Send a line without collecting its response."""

read_response() abstractmethod

Collect pending output, game-over and incomplete flags, and transport details.

Source code in src/mudgym/connections/connection.py
@abstractmethod
def read_response(self) -> tuple[bytes, bool, bool, dict[str, Any]]:
    """Collect pending output, game-over and incomplete flags, and transport details."""

invalidate() abstractmethod

Discard a failed session so it requires a successful reset before reuse.

Source code in src/mudgym/connections/connection.py
@abstractmethod
def invalidate(self) -> None:
    """Discard a failed session so it requires a successful reset before reuse."""

close() abstractmethod

Release the connection's resources.

Source code in src/mudgym/connections/connection.py
@abstractmethod
def close(self) -> None:
    """Release the connection's resources."""

mudgym.connections.wasm.wasmtime_provider.WasmtimeConnection

Bases: MudConnection

One stable MudGym connection rebound to each fresh WASI session.

Source code in src/mudgym/connections/wasm/wasmtime_provider.py
class WasmtimeConnection(MudConnection):
    """One stable MudGym connection rebound to each fresh WASI session."""

    def __init__(self, *, provider: WasmtimeProvider, connection_index: int, timeout_ms: int) -> None:
        self.provider = provider
        self.connection_index = connection_index
        self.timeout_ms = timeout_ms
        self.owns_provider = False
        self._session: WasmtimeSession | None = None
        self._started = False
        self._closed = False
        self._invalidated = False
        self._pending_responses: list[WasmtimeResponse] = []

    def _bind(self, session: WasmtimeSession) -> None:
        if self._pending_responses:
            raise RuntimeError(f"cannot replace connection {self.connection_index} with unread responses")
        self._session = session
        self._started = False
        self._invalidated = False

    def reset(self, *, seed: int | None = None) -> None:
        if self._closed:
            raise RuntimeError("Wasmtime connection is closed")
        if self.owns_provider:
            self.provider.reset(seed=seed)
        else:
            self.provider._prepare_connection(self, seed=seed)
        session = self._require_session()
        session.receive()
        ticket = session.send("sip t", self.timeout_ms)
        if ticket.status is not CommandTicketStatus.COMPLETED:
            raise RuntimeError(f"tearoom preparation returned {ticket.status.name}")
        session.receive()
        self._pending_responses.clear()
        self._started = True
        self._invalidated = False

    def _require_session(self) -> WasmtimeSession:
        if self._session is None:
            raise RuntimeError("Wasmtime connection has no live session; call reset() first")
        return self._session

    def _execute_line(self, command: str) -> WasmtimeResponse:
        command_bytes = encode_command_bytes(command)
        session = self._require_session()
        with session.world.lock:
            pending_output = session.receive()
            if session.departed:
                # The final receive retires the native handle. No command was sent.
                return WasmtimeResponse(
                    raw_bytes=pending_output,
                    game_bytes=pending_output,
                    echoed_line=None,
                    terminated=True,
                    incomplete=False,
                )
            ticket = session.send(command, self.timeout_ms)
            queued_output = b""
            if ticket.status is not CommandTicketStatus.HALTED:
                queued_output = session.receive()
        game_bytes = pending_output + ticket.output + queued_output
        raw_bytes = pending_output + command_bytes + b"\r\n" + ticket.output + queued_output
        # A drain may report departure with final bytes after a COMPLETED ticket.
        # Scan all engine output, including bytes queued before this command.
        terminated = (
            ticket.status
            in {
                CommandTicketStatus.HALTED,
                CommandTicketStatus.PLAYER_DEPARTED,
            }
            or session.departed
            or has_game_over_prompt(game_bytes)
        )
        incomplete = ticket.status is CommandTicketStatus.INPUT_REQUIRED
        if ticket.world_indeterminate:
            # An indeterminate world also returns FAILED, but must preserve the transition for truncation.
            incomplete = True
        elif ticket.status is CommandTicketStatus.FAILED:
            raise RuntimeError(f"WASI command {command!r} returned FAILED")
        if not terminated and not incomplete and not session.world.is_alive():
            incomplete = True
        return WasmtimeResponse(
            raw_bytes=raw_bytes,
            game_bytes=game_bytes,
            echoed_line=command,
            terminated=terminated,
            incomplete=incomplete,
        )

    def send_line(self, line: str) -> None:
        if not self._started:
            raise RuntimeError("Connection has not been reset, call reset() first.")
        if self._invalidated:
            raise ConnectionClosedError("Wasmtime connection was invalidated; call reset() before sending")
        if self._pending_responses:
            previous = self._pending_responses[-1]
            if previous.terminated or previous.incomplete:
                raise ConnectionClosedError("Wasmtime connection closed after the previous command line")
        self._pending_responses.append(self._execute_line(line))

    def read_response(self) -> tuple[bytes, bool, bool, dict[str, Any]]:
        if self._closed or not self._started:
            raise ConnectionClosedError("Wasmtime connection is not open. Call reset() before receiving.")
        responses = self._pending_responses
        self._pending_responses = []
        raw_bytes = b"".join(response.raw_bytes for response in responses)
        # Retain the engine bytes separately so synthetic echoes never become game control text.
        game_bytes = b"".join(response.game_bytes for response in responses)
        terminated = any(response.terminated for response in responses)
        incomplete = any(response.incomplete for response in responses)
        # Later peer commands and the coordinator's tick can enqueue output after our send.
        # Drain at the observation boundary, including passive reads during a joint reset.
        session = self._require_session()
        if not session.departed:
            queued_output = session.receive()
            raw_bytes += queued_output
            game_bytes += queued_output
        status = session.world.status()
        terminated = terminated or session.departed or status is WorldStatus.HALTED or has_game_over_prompt(game_bytes)
        incomplete = incomplete or status is WorldStatus.INDETERMINATE
        return (
            raw_bytes,
            terminated,
            incomplete,
            {
                "backend": "wasmtime",
                "connection_index": self.connection_index,
                "world_index": self.provider.world_for_connection(self.connection_index),
                "world_seed": session.world.seed,
                "persona": {
                    "name": session.persona.name,
                    "sex": session.persona.sex,
                },
                "bytes_length": len(raw_bytes),
                "sent_lines": [response.echoed_line for response in responses if response.echoed_line is not None],
            },
        )

    def advance_world_ticks(self, ticks: int) -> int:
        with self.provider._lock:
            if self._closed:
                raise RuntimeError("Wasmtime connection is closed")
            session = self._require_session()
            try:
                return session.world.tick(ticks, self.timeout_ms)
            except WasmtimeTerminalTick as terminal:
                world_index = self.provider.world_for_connection(self.connection_index)
                raise WorldAdvancementFailed(
                    f"world advancement failed for world {world_index}",
                    successes={},
                    failures={world_index: terminal},
                ) from terminal

    def tick_for_step(self) -> None:
        # Observation delivers the final transition when a world ends during this tick.
        with suppress(WorldAdvancementFailed):
            self.advance_world_ticks(1)

    def invalidate(self) -> None:
        self._pending_responses = []
        self._invalidated = True

    def _mark_closed(self) -> None:
        """Release Store-owning references after the provider is shut down."""
        self._pending_responses = []
        self._session = None
        self._closed = True
        self._started = False
        self._invalidated = True

    def close(self) -> None:
        if self._closed:
            return
        if self.owns_provider:
            self.provider.close()
            return
        self._pending_responses = []
        session = self._session
        if session is not None and session.world.is_alive() and not session.departed:
            session.world.remove_session(session, self.timeout_ms)
        self._mark_closed()

mudgym.connections.wasm.create_connection(*, persona=None, sex=None, **provider_options)

Create a standalone connection that owns and closes its entire provider.

Source code in src/mudgym/connections/wasm/wasmtime_provider.py
def create_connection(
    *, persona: str | None = None, sex: str | None = None, **provider_options: Any
) -> WasmtimeConnection:
    """Create a standalone connection that owns and closes its entire provider."""
    provider = WasmtimeProvider(personas=((persona, sex),), **provider_options)
    try:
        connection = provider.create_connections(1)[0]
        connection.owns_provider = True
        return connection
    except BaseException:
        provider.close()
        raise

mudgym.connections.provider.ConnectionProvider

Bases: Protocol

Provide connections backed by shared resources.

The provider determines how players are arranged across worlds. Once create_connections returns, the caller owns the connections. If creation fails, the provider cleans up that call's resources. The owning environment closes the provider after its connections.

Source code in src/mudgym/connections/provider.py
class ConnectionProvider(Protocol):
    """Provide connections backed by shared resources.

    The provider determines how players are arranged across worlds. Once create_connections returns, the caller owns the connections. If creation fails, the provider cleans up that call's resources. The owning environment closes the provider after its connections.
    """

    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 resources."""
        ...

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 resources.

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

mudgym.connections.wasm.WasmtimeProvider

MudGym provider spreading stable connections over fresh isolated WASI worlds.

Source code in src/mudgym/connections/wasm/wasmtime_provider.py
class WasmtimeProvider:
    """MudGym provider spreading stable connections over fresh isolated WASI worlds."""

    def __init__(
        self,
        *,
        runtime: WasmtimeRuntime | None = None,
        worlds: int | None = None,
        seed: int | None = None,
        civil_time_anchor: datetime = datetime(2026, 1, 1, tzinfo=UTC),
        timeout_ms: int = 5_000,
        personas: Sequence[tuple[str] | tuple[str | None, str | None]] | None = None,
        persona_pool: Sequence[tuple[str, str | None]] | None = None,
    ) -> None:
        # Resolve worlds=None when the factory supplies its connection count.
        # An explicit world count shares connections by modulo.
        if worlds is not None and (isinstance(worlds, bool) or not isinstance(worlds, int) or worlds < 1):
            raise ValueError("worlds must be a positive integer or None")
        if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or timeout_ms < 1:
            raise ValueError("timeout_ms must be a positive integer")
        if seed is None:
            # Leave room for world-index offsets within the engine's 53-bit seed range.
            seed = secrets.randbits(32)
        validate_seed(seed, label="seed")
        if worlds is not None:
            validate_seed(seed + worlds - 1, label="seed plus the final world index")
        self.personas = None if personas is None else tuple(parse_persona(entry) for entry in personas)
        self.persona_pool = (
            DEFAULT_PERSONA_POOL
            if persona_pool is None
            else validate_persona_pool(tuple(Persona(name, sex) for name, sex in persona_pool))
        )
        self.runtime = WasmtimeRuntime() if runtime is None else runtime
        self.worlds = worlds
        self.seed = seed
        self.civil_time_anchor = civil_time_anchor
        self.timeout_ms = timeout_ms
        self._lock = threading.RLock()
        self._executor: ThreadPoolExecutor | None = None
        self._connections: list[WasmtimeConnection] = []
        self._world_slots: list[list[int]] = []
        self._ordered_worlds: list[WasmtimeWorld] = []
        self._world_seeds: tuple[int, ...] = ()
        self._closed = False

    def world_for_connection(self, connection_index: int) -> int:
        if isinstance(connection_index, bool) or not isinstance(connection_index, int) or connection_index < 0:
            raise ValueError("connection_index must be a non-negative integer")
        if self._connections and connection_index >= len(self._connections):
            raise IndexError(f"connection index {connection_index} is outside {len(self._connections)} connections")
        return connection_index % self.worlds

    def create_connections(self, count: int) -> list[WasmtimeConnection]:
        with self._lock:
            if self._closed:
                raise RuntimeError("provider is closed")
            if self._connections:
                raise RuntimeError("provider has already created connections")
            if isinstance(count, bool) or not isinstance(count, int) or count < 1:
                raise ValueError("count must be a positive integer")
            # Commit the topology only after validation so a refused batch can be retried.
            resolved_worlds = count if self.worlds is None else self.worlds
            validate_seed(self.seed + resolved_worlds - 1, label="seed plus the final world index")
            if resolved_worlds > count:
                raise ValueError(f"worlds cannot exceed connections: {resolved_worlds} > {count}")
            largest_world_session_count = -(-count // resolved_worlds)
            validate_session_count(largest_world_session_count)
            if self.personas is not None and len(self.personas) != count:
                raise ValueError(f"personas must contain one entry per connection: {len(self.personas)} != {count}")
            if self.personas is None:
                self.personas = (Persona(),) * count
            self.worlds = resolved_worlds
            self._world_seeds = tuple(self.seed + world_index for world_index in range(resolved_worlds))
            self._world_slots = [list(range(index, count, resolved_worlds)) for index in range(resolved_worlds)]
            self._connections = [
                WasmtimeConnection(provider=self, connection_index=index, timeout_ms=self.timeout_ms)
                for index in range(count)
            ]
            self._executor = ThreadPoolExecutor(
                max_workers=min(resolved_worlds, os.process_cpu_count() or 1),
                thread_name_prefix="wasmtime-world",
            )
            return list(self._connections)

    def _seeds_for_reset(self, seed: int | list[int | None] | None) -> tuple[int, ...]:
        if seed is None:
            return self._world_seeds
        if isinstance(seed, int) and not isinstance(seed, bool):
            return tuple(
                validate_seed(seed + world_index, label=f"seed for world {world_index}")
                for world_index in range(self.worlds)
            )
        if not isinstance(seed, list):
            raise TypeError("provider reset seed must be an integer, a list of integers/None, or None")
        if len(seed) != len(self._connections):
            raise ValueError(
                f"provider reset seed list must contain one entry per connection: "
                f"expected {len(self._connections)}, got {len(seed)}"
            )
        # Validate every connection's seed before building worlds, including players sharing a world.
        for connection_index, candidate in enumerate(seed):
            if candidate is not None:
                validate_seed(candidate, label=f"seed[{connection_index}]")
        world_seeds = []
        for world_index in range(self.worlds):
            candidate = seed[world_index]
            world_seeds.append(self._world_seeds[world_index] if candidate is None else candidate)
        return tuple(world_seeds)

    def _build_world_with_sessions(
        self, slots: Sequence[int], seed: int
    ) -> tuple[WasmtimeWorld, list[WasmtimeSession]]:
        personas = resolve_personas([self.personas[index] for index in slots], pool=self.persona_pool, seed=seed)
        world = self.runtime.create_world(max_players=len(slots), seed=seed, civil_time_anchor=self.civil_time_anchor)
        try:
            sessions = [world.add_session(persona, self.timeout_ms) for persona in personas]
            return world, sessions
        except BaseException as error:
            if cleanup_errors := _shutdown_worlds([world]):
                raise BaseExceptionGroup("WASI session admission failed", [error, *cleanup_errors]) from error
            raise

    def _require_world_connections_quiescent(self, world_index: int) -> None:
        unread_connections = [
            connection_index
            for connection_index in self._world_slots[world_index]
            if self._connections[connection_index]._pending_responses
        ]
        if unread_connections:
            raise RuntimeError(
                f"cannot reset WASI world {world_index} while connections {unread_connections} have unread responses"
            )

    def _replace_connection_session(
        self,
        connection: WasmtimeConnection,
        world_index: int,
    ) -> None:
        """Replace one departed player without changing its active world."""
        self._require_world_connections_quiescent(world_index)
        world = self._ordered_worlds[world_index]
        previous_session = connection._require_session()
        if not previous_session.departed:
            world.remove_session(previous_session, connection.timeout_ms)
        new_session = world.add_session(previous_session.persona, self.timeout_ms)
        connection._bind(new_session)

    def _replace_world(self, world_index: int, *, seed: int | None = None) -> None:
        """Replace one Store and Instance without rebinding any other world."""
        self._require_world_connections_quiescent(world_index)
        slots = self._world_slots[world_index]
        seed = self._world_seeds[world_index] if seed is None else validate_seed(seed, label="world seed")
        new_world, sessions = self._build_world_with_sessions(slots, seed)
        old_world = self._ordered_worlds[world_index]
        for connection_index, session in zip(slots, sessions, strict=True):
            self._connections[connection_index]._bind(session)
        self._ordered_worlds[world_index] = new_world
        self._world_seeds = tuple(
            seed if index == world_index else previous for index, previous in enumerate(self._world_seeds)
        )
        old_world.shutdown()

    def reset(self, *, seed: int | list[int | None] | None = None) -> None:
        """Replace every Store and Instance, then rebind the stable connections."""
        with self._lock:
            if self._closed:
                raise RuntimeError("provider is closed")
            if self._executor is None:
                raise RuntimeError("cannot reset before creating connections")
            if any(connection._pending_responses for connection in self._connections):
                raise RuntimeError("cannot reset Wasmtime worlds while a connection has unread responses")
            world_seeds = self._seeds_for_reset(seed)
            futures = []
            try:
                built = ordered_future_results(
                    (
                        self._executor.submit(self._build_world_with_sessions, slots, world_seeds[world_index])
                        for world_index, slots in enumerate(self._world_slots)
                    ),
                    "WASI world reset failed",
                    submitted=futures,
                )
            except BaseException as error:
                # The collector has settled every future, including after a main-thread interruption.
                new_worlds = (
                    future.result()[0] for future in futures if not future.cancelled() and future.exception() is None
                )
                if cleanup_errors := _shutdown_worlds(new_worlds):
                    raise BaseExceptionGroup("WASI world reset failed", [error, *cleanup_errors]) from error
                raise

            new_worlds = [world for world, _ in built]
            for world_index, (_, sessions) in enumerate(built):
                for connection_index, session in zip(self._world_slots[world_index], sessions, strict=True):
                    self._connections[connection_index]._bind(session)

            old_worlds = self._ordered_worlds
            self._ordered_worlds = new_worlds
            self._world_seeds = world_seeds
            if shutdown_errors := _shutdown_worlds(old_worlds):
                raise BaseExceptionGroup("previous WASI world shutdown failed after reset", shutdown_errors)

    def _prepare_connection(self, connection: WasmtimeConnection, *, seed: int | None = None) -> None:
        with self._lock:
            if connection not in self._connections:
                raise RuntimeError("connection does not belong to this provider")
            if not self._ordered_worlds:
                self.reset(seed=seed)
                return
            if not connection._started:
                return

            world_index = self.world_for_connection(connection.connection_index)
            if seed is not None:
                if len(self._world_slots[world_index]) > 1:
                    raise ValueError("Cannot reseed one player in a shared world. Reset the whole provider instead.")
                self._replace_world(world_index, seed=seed)
                return
            world = self._ordered_worlds[world_index]
            session = connection._require_session()
            if world.is_alive() and (
                len(self._world_slots[world_index]) > 1 or connection._invalidated or session.departed
            ):
                self._replace_connection_session(connection, world_index)
                return
            self._replace_world(world_index)

    def advance_worlds(self, ticks: int) -> dict[int, int]:
        """Advance every world once, reporting a world that ended structurally.

        Healthy worlds still advance when a sibling ends. Terminal worlds are reported together as
        ``WorldAdvancementFailed`` so the step hook can leave their final transitions for observation.
        """

        def tick(world: WasmtimeWorld) -> int | WasmtimeTerminalTick:
            try:
                return world.tick(ticks, self.timeout_ms)
            except WasmtimeTerminalTick as terminal:
                return terminal

        with self._lock:
            if self._closed:
                raise RuntimeError("provider is closed")
            if not self._ordered_worlds or self._executor is None:
                raise RuntimeError("cannot advance worlds before reset")
            futures = (self._executor.submit(tick, world) for world in self._ordered_worlds)
            results = ordered_future_results(futures, "WASI world advancement failed")
            successes: dict[int, int] = {}
            failures: dict[int, WasmtimeTerminalTick] = {}
            for world_index, result in enumerate(results):
                if isinstance(result, WasmtimeTerminalTick):
                    failures[world_index] = result
                else:
                    successes[world_index] = result
            if failures:
                raise WorldAdvancementFailed(
                    f"world advancement failed for {len(failures)} of {len(results)} worlds "
                    f"({len(successes)} advanced)",
                    successes=successes,
                    failures=failures,
                )
            return successes

    def tick_for_step(self) -> None:
        # Terminal worlds retain their observations while healthy siblings still advance.
        with suppress(WorldAdvancementFailed):
            self.advance_worlds(1)

    def close(self) -> None:
        with self._lock:
            if self._closed:
                return
            self._closed = True
            errors = _shutdown_worlds(self._ordered_worlds)
            self._ordered_worlds = []
            for connection in self._connections:
                connection._mark_closed()
            self._connections = []
            self._world_slots = []
            if self._executor is not None:
                self._executor.shutdown(wait=True, cancel_futures=True)
                self._executor = None
            if errors:
                raise BaseExceptionGroup("WASI provider close failed", errors)

reset(*, seed=None)

Replace every Store and Instance, then rebind the stable connections.

Source code in src/mudgym/connections/wasm/wasmtime_provider.py
def reset(self, *, seed: int | list[int | None] | None = None) -> None:
    """Replace every Store and Instance, then rebind the stable connections."""
    with self._lock:
        if self._closed:
            raise RuntimeError("provider is closed")
        if self._executor is None:
            raise RuntimeError("cannot reset before creating connections")
        if any(connection._pending_responses for connection in self._connections):
            raise RuntimeError("cannot reset Wasmtime worlds while a connection has unread responses")
        world_seeds = self._seeds_for_reset(seed)
        futures = []
        try:
            built = ordered_future_results(
                (
                    self._executor.submit(self._build_world_with_sessions, slots, world_seeds[world_index])
                    for world_index, slots in enumerate(self._world_slots)
                ),
                "WASI world reset failed",
                submitted=futures,
            )
        except BaseException as error:
            # The collector has settled every future, including after a main-thread interruption.
            new_worlds = (
                future.result()[0] for future in futures if not future.cancelled() and future.exception() is None
            )
            if cleanup_errors := _shutdown_worlds(new_worlds):
                raise BaseExceptionGroup("WASI world reset failed", [error, *cleanup_errors]) from error
            raise

        new_worlds = [world for world, _ in built]
        for world_index, (_, sessions) in enumerate(built):
            for connection_index, session in zip(self._world_slots[world_index], sessions, strict=True):
                self._connections[connection_index]._bind(session)

        old_worlds = self._ordered_worlds
        self._ordered_worlds = new_worlds
        self._world_seeds = world_seeds
        if shutdown_errors := _shutdown_worlds(old_worlds):
            raise BaseExceptionGroup("previous WASI world shutdown failed after reset", shutdown_errors)

advance_worlds(ticks)

Advance every world once, reporting a world that ended structurally.

Healthy worlds still advance when a sibling ends. Terminal worlds are reported together as WorldAdvancementFailed so the step hook can leave their final transitions for observation.

Source code in src/mudgym/connections/wasm/wasmtime_provider.py
def advance_worlds(self, ticks: int) -> dict[int, int]:
    """Advance every world once, reporting a world that ended structurally.

    Healthy worlds still advance when a sibling ends. Terminal worlds are reported together as
    ``WorldAdvancementFailed`` so the step hook can leave their final transitions for observation.
    """

    def tick(world: WasmtimeWorld) -> int | WasmtimeTerminalTick:
        try:
            return world.tick(ticks, self.timeout_ms)
        except WasmtimeTerminalTick as terminal:
            return terminal

    with self._lock:
        if self._closed:
            raise RuntimeError("provider is closed")
        if not self._ordered_worlds or self._executor is None:
            raise RuntimeError("cannot advance worlds before reset")
        futures = (self._executor.submit(tick, world) for world in self._ordered_worlds)
        results = ordered_future_results(futures, "WASI world advancement failed")
        successes: dict[int, int] = {}
        failures: dict[int, WasmtimeTerminalTick] = {}
        for world_index, result in enumerate(results):
            if isinstance(result, WasmtimeTerminalTick):
                failures[world_index] = result
            else:
                successes[world_index] = result
        if failures:
            raise WorldAdvancementFailed(
                f"world advancement failed for {len(failures)} of {len(results)} worlds "
                f"({len(successes)} advanced)",
                successes=successes,
                failures=failures,
            )
        return successes