Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 74 additions & 9 deletions environments/text_2048/src/hud_controller/setup/board.py
Original file line number Diff line number Diff line change
@@ -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"),
]
86 changes: 86 additions & 0 deletions hud/tests/test_text_2048_setup_board.py
Original file line number Diff line number Diff line change
@@ -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