diff --git a/environments/text_2048/src/hud_controller/setup/board.py b/environments/text_2048/src/hud_controller/setup/board.py index 52a521fb..1e99ce77 100644 --- a/environments/text_2048/src/hud_controller/setup/board.py +++ b/environments/text_2048/src/hud_controller/setup/board.py @@ -1,21 +1,86 @@ """Board-size setup function for 2048.""" -from mcp.types import TextContent, ContentBlock +from __future__ import annotations + +import json +from contextlib import contextmanager +from typing import Any + +from mcp.types import ContentBlock, TextContent + +try: + from hud.telemetry import trace +except ModuleNotFoundError: # pragma: no cover - optional dependency safeguard + + @contextmanager + def trace(*_args: Any, **_kwargs: Any) -> Any: # type: ignore[misc] + yield + from . import setup +DEFAULT_BOARD_SIZE = 4 +MIN_BOARD_SIZE = 3 +MAX_BOARD_SIZE = 8 + + +def _normalize_board_size(value: Any) -> tuple[int, str | None]: + """Convert ``value`` into a clamped integer plus a note if coercion was needed.""" + note: str | None = None + try: + size = int(value) + except (TypeError, ValueError): + size = DEFAULT_BOARD_SIZE + note = ( + f"Invalid board size {value!r}; defaulting to {DEFAULT_BOARD_SIZE}." + ) + return size, note + + if size < MIN_BOARD_SIZE: + note = ( + f"Requested board size {size} is below {MIN_BOARD_SIZE}; " + f"using {MIN_BOARD_SIZE}." + ) + return MIN_BOARD_SIZE, note + + if size > MAX_BOARD_SIZE: + note = ( + f"Requested board size {size} exceeds {MAX_BOARD_SIZE}; " + f"using {MAX_BOARD_SIZE}." + ) + return MAX_BOARD_SIZE, note + + return size, None + @setup.tool("board") -async def setup_board(board_size: int = 4) -> list[ContentBlock]: +async def setup_board(board_size: int = DEFAULT_BOARD_SIZE) -> list[ContentBlock]: """Initialize a new game with the specified board size.""" + normalized_size, validation_note = _normalize_board_size(board_size) game = setup.env - game.reset(size=board_size) - # Get the initial board state to show the agent - board_display = game.get_board_ascii() + with trace( + "text-2048 setup", + attrs={ + "requested_board_size": board_size, + "board_size": normalized_size, + "validation_note": validation_note or "", + }, + ): + game.reset(size=normalized_size) + board_display = game.get_board_ascii() + state_payload = { + "requested_board_size": board_size, + "board_size": normalized_size, + "state": game.get_state(), + } + + lines = [f"{normalized_size}x{normalized_size} game initialized"] + if validation_note: + lines.append(validation_note) + lines.append("") + lines.append(board_display) - # Return the initial board display return [ - TextContent( - text=f"{board_size}x{board_size} game initialized\n\n{board_display}", type="text" - ) + TextContent(text="\n".join(lines), type="text"), + TextContent(text=json.dumps(state_payload), type="text"), ] diff --git a/hud/tests/test_text_2048_setup_board.py b/hud/tests/test_text_2048_setup_board.py new file mode 100644 index 00000000..d2e18909 --- /dev/null +++ b/hud/tests/test_text_2048_setup_board.py @@ -0,0 +1,86 @@ +"""Tests for the text-2048 setup board tool.""" + +from __future__ import annotations + +import json +import sys +from contextlib import contextmanager +from pathlib import Path + +import pytest + +# Ensure the environment package is importable from the repository root +REPO_ROOT = Path(__file__).resolve().parents[2] +ENV_SRC = REPO_ROOT / "environments" / "text_2048" / "src" +if str(ENV_SRC) not in sys.path: + sys.path.append(str(ENV_SRC)) + +from hud_controller.setup import board as board_module # noqa: E402 + + +class DummyGame: + """Minimal game stub for exercising setup_board.""" + + def __init__(self) -> None: + self.size = 0 + self.reset_calls: list[int] = [] + + def reset(self, size: int = 4) -> None: + self.size = size + self.reset_calls.append(size) + + def get_board_ascii(self) -> str: + return f"{self.size}x{self.size} board" + + def get_state(self) -> dict: + return { + "board": [[self.size]], + "score": 0, + "moves": 0, + "game_over": False, + "won": False, + "highest_tile": self.size, + } + + +@pytest.fixture(autouse=True) +def stub_trace(monkeypatch: pytest.MonkeyPatch) -> None: + """Replace telemetry trace context manager with a no-op.""" + + @contextmanager + def _noop_trace(*args, **kwargs): # noqa: ANN001, ANN003 + yield + + monkeypatch.setattr(board_module, "trace", _noop_trace) + + +@pytest.mark.asyncio +async def test_setup_board_returns_ascii_and_json(monkeypatch: pytest.MonkeyPatch) -> None: + game = DummyGame() + monkeypatch.setattr(board_module.setup, "env", game, raising=False) + + result = await board_module.setup_board.fn(board_size=5) + assert len(result) == 2 + + ascii_block, json_block = result + assert "5x5 game initialized" in ascii_block.text + + payload = json.loads(json_block.text) + assert payload["board_size"] == 5 + assert payload["state"]["board"] == [[5]] + + +@pytest.mark.asyncio +async def test_setup_board_clamps_out_of_range_values(monkeypatch: pytest.MonkeyPatch) -> None: + game = DummyGame() + monkeypatch.setattr(board_module.setup, "env", game, raising=False) + + result = await board_module.setup_board.fn(board_size=99) + ascii_block, json_block = result + + assert str(board_module.MAX_BOARD_SIZE) in ascii_block.text + assert "using" in ascii_block.text.lower() + + payload = json.loads(json_block.text) + assert payload["board_size"] == board_module.MAX_BOARD_SIZE + assert payload["requested_board_size"] == 99