Files
turnstone/examples/door-game/tests/test_screen.py
Patrick Buckley 99e7dc17ec feat(examples): Understone — a BBS door game as a standalone MCP server
A shared-world, classic-door-game-style door game in examples/door-game/: a pure-stdlib
game engine (tile overworld + location menus, seeded combat, daily turn
budget, leveling, shop, event log, leaderboard) behind nine sync door_*
FastMCP tools returning monochrome box-drawing frames. The connecting
session's LLM plays dungeon master — tool descriptions plus a door_help
manual teach a cold model to run the game with zero setup, while the server
owns all dice and state, so the DM narrates around facts it cannot bend.

Non-obvious decisions:
- engine/screen/world/persistence import stdlib only; server.py is the only
  mcp import. All nine handlers are sync def: on mcp 1.27 they execute
  inline on the event loop (verified against func_metadata), so tool bodies
  serialize and one SQLite connection (WAL, per-action commit) is safe.
  check_same_thread=False exists only because the Store may be constructed
  on a different thread than the serving loop.
- Streamable HTTP serves ONE process = one shared world (players appear on
  each other's maps; async "while you were away" event feed); stdio is the
  solo-world fallback.
- The economy is content, not code: daily_turns, costs, xp curve, bestow
  budget, and dungeon tiers live in world.json settings, band-validated by
  the loader. door_bestow gives the DM capped, event-audited largesse
  (gold/heal only, never turns) so story generosity cannot melt the shared
  leaderboard.
- Player names and bestow reasons are sanitized (printable-only, length
  caps) because they flow into the shared event log and from there into
  other players' DM context — embedded newlines would forge log lines.
- Daily turn/bestow pools lazy-reset per UTC day on every consuming path
  (injectable clock); the dungeon gauntlet is a fixed boss ladder by design.

Tests: 109 — engine units with seeded RNG + frozen clock, hand-authored
golden frames paired with structural asserts, loader band rejections, and
one real-wire integration test (uvicorn + streamablehttp_client) with a
two-session shared-world assertion. Negative-tested by reverting the guard
and watching the suite fail: the daily turn-budget guard, the bestow cap,
and the sanitizer's isprintable clause.
2026-06-13 04:40:40 -07:00

170 lines
5.6 KiB
Python

"""Screen-layer tests: viewport maths, frame rendering, menu rendering.
Golden discipline: the golden files under ``tests/golden`` are authored by
hand (correct borders/centring, eyeballed) and are NOT machine-dumped
renderer output. Every golden comparison is paired with structural asserts
that hold independent of the exact golden bytes, so a renderer regression
that happens to match a stale golden still trips a structural check.
"""
from __future__ import annotations
from pathlib import Path
from understone.screen.grid import Cell, CellGrid
from understone.screen.menus import render_menu
from understone.screen.palette import Color
from understone.screen.text_renderer import render_frame
from understone.screen.viewport import compute_window
GOLDEN = Path(__file__).parent / "golden"
# ---------------------------------------------------------------------------
# viewport.compute_window
# ---------------------------------------------------------------------------
def test_window_centers_when_interior() -> None:
# 100x100 map, 48x16 view, focus at (50, 50): centred.
x0, y0 = compute_window(100, 100, 48, 16, 50, 50)
assert x0 == 50 - 48 // 2
assert y0 == 50 - 16 // 2
def test_window_clamps_nw_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 0, 0)
assert (x0, y0) == (0, 0)
def test_window_clamps_ne_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 99, 0)
assert x0 == 100 - 48
assert y0 == 0
def test_window_clamps_sw_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 0, 99)
assert x0 == 0
assert y0 == 100 - 16
def test_window_clamps_se_corner() -> None:
x0, y0 = compute_window(100, 100, 48, 16, 99, 99)
assert x0 == 100 - 48
assert y0 == 100 - 16
def test_window_view_larger_than_map_pins_origin() -> None:
x0, y0 = compute_window(10, 8, 48, 16, 5, 4)
assert (x0, y0) == (0, 0)
# ---------------------------------------------------------------------------
# Shared small-grid builders for the golden frames
# ---------------------------------------------------------------------------
_FLOOR = Cell(".", Color.FLOOR)
_PLAYER = Cell("@", Color.PLAYER)
def _floor_grid(rows: int, cols: int) -> CellGrid:
grid = CellGrid(rows, cols)
for r in range(rows):
for c in range(cols):
grid.set(r, c, _FLOOR)
return grid
def _spawn_grid() -> CellGrid:
"""9x5 floor with the player centred at (row 2, col 4)."""
grid = _floor_grid(5, 9)
grid.set(2, 4, _PLAYER)
return grid
def _edge_nw_grid() -> CellGrid:
"""9x5 floor with the player pinned to the NW corner (row 0, col 0)."""
grid = _floor_grid(5, 9)
grid.set(0, 0, _PLAYER)
return grid
# ---------------------------------------------------------------------------
# text_renderer.render_frame
# ---------------------------------------------------------------------------
def test_render_frame_matches_golden_spawn() -> None:
frame = render_frame(_spawn_grid(), title="Vale", status="[ status ]")
expected = (GOLDEN / "viewport_spawn.txt").read_text(encoding="utf-8")
assert frame == expected.rstrip("\n")
def test_render_frame_matches_golden_edge_nw() -> None:
frame = render_frame(_edge_nw_grid(), title="Vale", status="[ status ]")
expected = (GOLDEN / "viewport_edge_nw.txt").read_text(encoding="utf-8")
assert frame == expected.rstrip("\n")
def test_render_frame_structural_invariants() -> None:
frame = render_frame(_spawn_grid(), title="Vale", status="[ status ]")
lines = frame.split("\n")
# Top border, 5 grid rows, bottom border, status = 8 lines.
assert len(lines) == 8
# Title substring lives in the top border.
assert "Vale" in lines[0]
# Uniform width across the box (top border through bottom border).
box_lines = lines[:-1]
widths = {len(line) for line in box_lines}
assert len(widths) == 1, f"box rows ragged: {widths}"
# Exactly one '@' and it sits at the centre column of the interior.
body = lines[1:-2]
at_positions = [(r, line.index("@")) for r, line in enumerate(body) if "@" in line]
assert len(at_positions) == 1
_, col = at_positions[0]
# Interior centre: 1 (left border) + cols//2 = 1 + 4 = 5.
assert col == 1 + 9 // 2
# Status line is preserved verbatim as the last line.
assert lines[-1] == "[ status ]"
def test_render_frame_under_size_budget() -> None:
grid = _floor_grid(16, 48)
grid.set(8, 24, _PLAYER)
frame = render_frame(grid, title="The Vale of Understone", status="[ a long status line here ]")
assert len(frame) < 2048
# ---------------------------------------------------------------------------
# menus.render_menu
# ---------------------------------------------------------------------------
def test_render_menu_matches_golden_inn() -> None:
menu = render_menu(
"The Sleeping Drake",
["A warm hearth crackles.", "A bed costs 15 gold."],
["(R)est", "(L)eave"],
"[ status ]",
)
expected = (GOLDEN / "menu_inn.txt").read_text(encoding="utf-8")
assert menu == expected.rstrip("\n")
def test_render_menu_structural_invariants() -> None:
menu = render_menu(
"The Sleeping Drake",
["A warm hearth crackles.", "A bed costs 15 gold."],
["(R)est", "(L)eave"],
"[ status ]",
)
lines = menu.split("\n")
assert "The Sleeping Drake" in lines[0]
assert lines[-1] == "[ status ]"
box = lines[:-1]
widths = {len(line) for line in box}
assert len(widths) == 1, f"menu box ragged: {widths}"
# Option line is present inside the body.
assert any("(R)est" in line and "(L)eave" in line for line in lines)