mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
9f54a97cc6
The opening hours were a gold/HP death spiral: a fresh hero spent more gold healing a fight than the kill paid, mid-tier foes out-damaged a starting HP bar, the map gave no read on where harder foes spawned, and a spent day left the player idle until the UTC rollover. This eases the on-ramp across both shipped worlds. Economy - Healer drops from 2 to 1 gold per HP, so topping up no longer outruns income. - Starting purse 20 -> 37: enough to buy the cheapest armor and one potion up front, a one-point DEF bump plus a heal cushion the player chooses to spend. Combat - Tier 2-4 common foes lose 1 ATK each, trimming the burst that could halve or end a fresh hero in a single bout. Rares and the boss are untouched. Wayfinding - The road glyph changes from "=" to a shaded path that reads as one continuous road in every orientation; "=" only looked right horizontally and broke into stacked dashes on vertical runs. Cinder's basalt path gets the same treatment with a crosshatch glyph free in its palette. Rest - Sleeping at the inn now rolls a spent adventurer into a fresh day's turns (it already fully heals). The top-up fires only at zero turns, so it never banks past the daily cap. Verified: full suite green (+2 rest tests, ruff/mypy clean); the greedy balance bot still clears the world 11/12 seeds at an unchanged pace on both packs.
859 lines
34 KiB
Python
859 lines
34 KiB
Python
"""Game façade integration tests over the shipped world.
|
||
|
||
Drives a full session against a temp store, a frozen clock, and a seeded
|
||
RNG: join -> status -> look -> move -> action(buy/rest/fight) -> log ->
|
||
rank -> bestow. Persistence is exercised by reopening the store.
|
||
|
||
Negative-test discipline (turn guard and bestow cap):
|
||
Two guards are pinned by assertions here. To confirm each assertion has
|
||
teeth, the implementer temporarily reverted the guard line and observed
|
||
the matching test FAIL, then restored it:
|
||
|
||
* Turn guard (engine/turns.py spend_turn): replacing
|
||
``if player.turns_left <= 0: return False`` with ``return True``
|
||
let fighting continue past the daily budget — ``test_turn_budget_blocks``
|
||
then failed on the "spent for today" assertion. Restored.
|
||
* Bestow cap (game.py bestow): removing the ``if cost > remaining``
|
||
refusal let an over-budget bestowal through — ``test_bestow_cap_refuses``
|
||
then failed on the unchanged-gold assertion. Restored.
|
||
* Sanitizer control-char guard (game.py _sanitize): disabling the
|
||
``not cleaned.isprintable()`` clause let a newline-injected name create a
|
||
player row and a public event — ``test_join_rejects_control_char_name``
|
||
then failed. Restored. (See the comment block above the hygiene tests.)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import unicodedata
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from tests.conftest import fixed_clock, utc
|
||
from understone.engine.models import Mode
|
||
from understone.engine.rng import GameRNG
|
||
from understone.game import Game
|
||
from understone.persistence import Store
|
||
from understone.world.loader import load_world
|
||
|
||
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
|
||
|
||
|
||
@pytest.fixture
|
||
def clock() -> object:
|
||
return fixed_clock(utc(2026, 6, 12, 10, 0))
|
||
|
||
|
||
def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
|
||
world = load_world(PACK)
|
||
store = Store(tmp_path / "game.db")
|
||
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Join / status / look
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_join_creates_player_at_spawn(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
out = game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
assert (player.x, player.y) == game.world.spawn
|
||
assert player.gold == game.world.settings.starting_gold
|
||
assert "@" in out
|
||
assert game.world.name in out
|
||
|
||
|
||
def test_join_resumes_existing(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
game.players["Brandr"].gold = 123
|
||
out = game.join("Brandr")
|
||
assert "Welcome back" in out
|
||
assert game.players["Brandr"].gold == 123
|
||
|
||
|
||
def test_status_unknown_player_is_friendly(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
out = game.status("Nobody")
|
||
assert "has signed the ledger" in out
|
||
assert "door_join" in out
|
||
|
||
|
||
def test_look_overworld_has_frame(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
out = game.look("Brandr")
|
||
assert "@" in out
|
||
assert "┌" in out and "┐" in out
|
||
assert len(out) < 2048
|
||
|
||
|
||
def test_overworld_frame_textured_borders_intact(tmp_path: Path, clock: object) -> None:
|
||
"""The textured overworld frame keeps square borders and a single player marker.
|
||
|
||
Structural discipline for the v0.6 texture: variants change the GLYPHS but
|
||
must never change the geometry. The box rows are uniform width, exactly one
|
||
'@' is painted, and the grass field shows more than one variant in a row
|
||
(the deterministic stipple, not a flat sheet of '.').
|
||
"""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
frame = game.look("Brandr")
|
||
lines = frame.split("\n")
|
||
# Box rows: top border + VIEW_H grid rows + bottom border, all equal width.
|
||
box = [ln for ln in lines if ln and ln[0] in "┌│└"]
|
||
widths = {len(ln) for ln in box}
|
||
assert len(widths) == 1, f"textured frame rows ragged: {widths}"
|
||
# Exactly one player marker, regardless of the surrounding texture.
|
||
assert frame.count("@") == 1
|
||
# The grass texture varies: a body row carries at least two of . , '
|
||
body = [ln for ln in lines if ln.startswith("│")]
|
||
assert any(len({ch for ch in ln if ch in ".,'"}) >= 2 for ln in body)
|
||
|
||
|
||
def test_look_in_menu_shows_location(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
# Shop is two cells east of spawn along the road.
|
||
game.move("Brandr", "", "east", 2)
|
||
assert game.players["Brandr"].mode is Mode.MENU
|
||
out = game.look("Brandr")
|
||
assert "(B)uy" in out and "(L)eave" in out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Move
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_move_blocked_in_menu(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
game.move("Brandr", "", "east", 2) # into the shop menu
|
||
out = game.move("Brandr", "", "east", 2)
|
||
assert "inside" in out.lower()
|
||
|
||
|
||
def test_move_enters_location(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
out = game.move("Brandr", "", "west", 2) # inn is two cells west
|
||
assert game.players["Brandr"].at_location == "inn"
|
||
assert "step inside" in out.lower()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Actions: rest, fight, turn budget
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_rest_heals_and_charges(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
player.hp = 5
|
||
game.move("Brandr", "", "west", 2) # inn
|
||
out = game.action("Brandr", "rest", "", "")
|
||
assert player.hp == player.max_hp
|
||
assert player.gold == game.world.settings.starting_gold - game.world.settings.rest_cost
|
||
assert "full health" in out.lower()
|
||
|
||
|
||
def test_rest_when_spent_restores_a_fresh_days_turns(tmp_path: Path, clock: object) -> None:
|
||
"""Sleeping at the inn with no turns left rolls into a fresh day's allowance."""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
daily = game.world.settings.daily_turns
|
||
player.turns_left = 0 # spent for the day
|
||
player.hp = 5
|
||
game.move("Brandr", "", "west", 2) # step into the inn
|
||
out = game.action("Brandr", "rest", "", "")
|
||
assert player.turns_left == daily # a fresh day's turns restored
|
||
assert player.hp == player.max_hp # and fully mended
|
||
assert f"/{daily} ]" in out # footer reflects the refreshed budget
|
||
|
||
|
||
def test_rest_with_turns_in_hand_never_inflates_the_budget(tmp_path: Path, clock: object) -> None:
|
||
"""Resting mid-day mends but adds no turns — the top-up only fires at zero."""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
daily = game.world.settings.daily_turns
|
||
player.turns_left = daily - 3 # turns still in hand
|
||
player.hp = 5
|
||
game.move("Brandr", "", "west", 2) # step into the inn
|
||
game.action("Brandr", "rest", "", "")
|
||
assert player.turns_left == daily - 3 # unchanged: no farming past the cap
|
||
assert player.hp == player.max_hp # but the heal still lands
|
||
|
||
|
||
def test_fight_spends_a_turn_and_credits(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
# Drop into the forest_near zone so an encounter is available.
|
||
player.x, player.y = 35, 25
|
||
before_turns = player.turns_left
|
||
out = game.action("Brandr", "fight", "", "")
|
||
assert player.turns_left == before_turns - 1
|
||
assert player.xp > 0
|
||
assert "XP" in out
|
||
|
||
|
||
def test_turn_budget_blocks(tmp_path: Path, clock: object) -> None:
|
||
"""Pins the spend_turn guard: at 0 turns, fighting is refused.
|
||
|
||
See the module docstring for the revert-and-observe-failure check that
|
||
proves this assertion has teeth.
|
||
"""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
player.x, player.y = 35, 25
|
||
player.turns_left = 0
|
||
out = game.action("Brandr", "fight", "", "")
|
||
assert "spent for today" in out.lower()
|
||
# No turn was consumed past zero, and no XP was gained.
|
||
assert player.turns_left == 0
|
||
assert player.xp == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Log / rank
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_log_reports_then_advances(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
# A second player acting creates a public event Brandr has not yet seen.
|
||
game.join("Sigrun")
|
||
first = game.log("Brandr")
|
||
assert "Sigrun" in first or "Brandr" in first
|
||
assert "The Understone Herald" in first # dressed as the broadsheet
|
||
# The cursor advanced; a second read with no new events is quiet.
|
||
second = game.log("Brandr")
|
||
assert "The Understone Herald" in second # the masthead still prints
|
||
assert "still" in second.lower() # the herald-flavoured "all quiet" line
|
||
|
||
|
||
def test_rank_marks_caller(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
game.join("Sigrun")
|
||
game.players["Sigrun"].level = 5
|
||
out = game.rank("Brandr")
|
||
assert "Brandr" in out and "Sigrun" in out
|
||
assert "*" in out # the caller's row is marked
|
||
assert "┌" in out # box-drawing table
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Rank ★ column: stars live in their own column, so a long name keeps them
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_win_stars_column_formats() -> None:
|
||
"""Zero is blank, 1..5 render as ★ runs, and >5 collapses to ★xN."""
|
||
from understone.game import _win_stars
|
||
|
||
assert _win_stars(0) == ""
|
||
assert _win_stars(1) == "★"
|
||
assert _win_stars(5) == "★★★★★"
|
||
assert _win_stars(7) == "★x7"
|
||
|
||
|
||
def test_long_name_with_one_win_keeps_its_star() -> None:
|
||
"""A full 24-char name no longer eats its own ★ (the v0.1 truncation bug).
|
||
|
||
The name occupied the whole 20-wide field before, clipping the star away;
|
||
with a separate stars column the ★ survives beside a maximal name.
|
||
"""
|
||
from understone.engine.rank import RankEntry
|
||
from understone.game import _render_rank_table
|
||
|
||
name = "X" * 24
|
||
rows = _render_rank_table([RankEntry(name=name, level=5, xp=100, gold=50, wins=1)], caller="")
|
||
body = "\n".join(rows)
|
||
assert name in body # the full name is present
|
||
assert "★" in body # and so is its star
|
||
|
||
|
||
def test_high_win_count_renders_compact_marker() -> None:
|
||
"""Seven wins render as the compact ``★x7`` rather than seven glyphs."""
|
||
from understone.engine.rank import RankEntry
|
||
from understone.game import _render_rank_table
|
||
|
||
rows = _render_rank_table([RankEntry(name="Champ", level=9, xp=9, gold=9, wins=7)], caller="")
|
||
body = "\n".join(rows)
|
||
assert "★x7" in body
|
||
assert "★★★★★★★" not in body # not seven literal stars
|
||
|
||
|
||
def test_shared_world_other_player_marker(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
game.join("Sigrun")
|
||
# Stand Sigrun one cell east of Brandr's spawn so she lands in the view.
|
||
sig = game.players["Sigrun"]
|
||
brandr = game.players["Brandr"]
|
||
sig.x, sig.y = brandr.x + 1, brandr.y
|
||
out = game.look("Brandr")
|
||
assert "☻" in out # the other player shows as '☻'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Bestow (+ cap negative test)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_bestow_grants_gold(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
before = player.gold
|
||
out = game.bestow("Brandr", "a daring rescue", 10, 0)
|
||
assert player.gold == before + 10
|
||
assert player.bestow_spent == 10
|
||
assert "bestowal" in out.lower()
|
||
|
||
|
||
def test_bestow_heal_charges_only_applied(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
player.hp = player.max_hp - 3 # only 3 missing
|
||
game.bestow("Brandr", "mercy after a hard fight", 0, 10)
|
||
assert player.hp == player.max_hp
|
||
# Charged for 3 HP at heal_cost_per_hp, not the requested 10.
|
||
assert player.bestow_spent == 3 * game.world.settings.heal_cost_per_hp
|
||
|
||
|
||
def test_bestow_requires_reason(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
out = game.bestow("Brandr", " ", 10, 0)
|
||
assert "reason" in out.lower()
|
||
assert game.players["Brandr"].gold == game.world.settings.starting_gold
|
||
|
||
|
||
def test_bestow_requires_nonzero(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
out = game.bestow("Brandr", "nothing at all", 0, 0)
|
||
assert "at least" in out.lower()
|
||
|
||
|
||
def test_bestow_cap_refuses(tmp_path: Path, clock: object) -> None:
|
||
"""Pins the bestow cap: an over-budget grant is refused without mutation.
|
||
|
||
See the module docstring for the revert-and-observe-failure check that
|
||
proves this assertion has teeth.
|
||
"""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
budget = game.world.settings.bestow_daily_budget
|
||
before_gold = player.gold
|
||
out = game.bestow("Brandr", "an absurd windfall", budget + 100, 0)
|
||
assert "the fates allow" in out.lower()
|
||
# Refused cleanly: no gold moved and no pool spent.
|
||
assert player.gold == before_gold
|
||
assert player.bestow_spent == 0
|
||
|
||
|
||
def test_bestow_pool_resets_next_day(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
game.bestow("Brandr", "first blessing", 20, 0)
|
||
assert player.bestow_spent == 20
|
||
# Advance the clock past UTC midnight; the next bestow sees a fresh pool.
|
||
game.clock = fixed_clock(utc(2026, 6, 13, 0, 5)) # type: ignore[assignment]
|
||
game.bestow("Brandr", "a new day's fortune", 20, 0)
|
||
assert player.bestow_spent == 20 # reset to 0 then +20, not 40
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Persistence round-trip through the façade
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_state_survives_store_reopen(tmp_path: Path, clock: object) -> None:
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
game.players["Brandr"].x, game.players["Brandr"].y = 35, 25
|
||
game.action("Brandr", "fight", "", "")
|
||
xp_after = game.players["Brandr"].xp
|
||
gold_after = game.players["Brandr"].gold
|
||
game.store.close()
|
||
|
||
world = load_world(PACK)
|
||
reopened = Store(tmp_path / "game.db")
|
||
revived = Game(world, reopened, clock=clock) # type: ignore[arg-type]
|
||
assert revived.players["Brandr"].xp == xp_after
|
||
assert revived.players["Brandr"].gold == gold_after
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Day rollover applies to fight/descend, not just join/bestow
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _MutableClock:
|
||
"""A clock whose reported moment can be advanced between calls."""
|
||
|
||
def __init__(self, moment: object) -> None:
|
||
self.moment = moment
|
||
|
||
def __call__(self) -> object:
|
||
return self.moment
|
||
|
||
|
||
def test_fight_refreshes_budget_across_midnight(tmp_path: Path) -> None:
|
||
"""A fight on a new UTC day must reset the budget without re-joining.
|
||
|
||
Before the fix, _resolve_encounter spent a turn without calling
|
||
_ensure_day, so an exhausted player who returned the next day was still
|
||
blocked until they happened to re-join.
|
||
"""
|
||
clk = _MutableClock(utc(2026, 6, 12, 23, 0))
|
||
world = load_world(PACK)
|
||
store = Store(tmp_path / "game.db")
|
||
game = Game(world, store, clock=clk, rng=GameRNG(seed=7)) # type: ignore[arg-type]
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
player.x, player.y = 35, 25 # forest_near zone: an encounter is available
|
||
player.turns_left = 0 # spent for the day
|
||
daily = game.world.settings.daily_turns
|
||
|
||
clk.moment = utc(2026, 6, 13, 0, 5) # cross UTC midnight, no re-join
|
||
out = game.action("Brandr", "fight", "", "")
|
||
|
||
assert "spent for today" not in out.lower() # the fresh day let the fight run
|
||
assert player.turns_left == daily - 1 # reset to full, then one spent
|
||
assert player.xp > 0
|
||
assert f"/{daily} ]" in out # footer shows the refreshed budget
|
||
|
||
|
||
def test_descend_refreshes_budget_across_midnight(tmp_path: Path) -> None:
|
||
"""Descending on a new UTC day resets the budget without re-joining."""
|
||
clk = _MutableClock(utc(2026, 6, 12, 23, 0))
|
||
world = load_world(PACK)
|
||
store = Store(tmp_path / "game.db")
|
||
game = Game(world, store, clock=clk, rng=GameRNG(seed=7)) # type: ignore[arg-type]
|
||
game.join("Hero")
|
||
player = game.players["Hero"]
|
||
# Overwhelming stats so the gauntlet itself never bounces the player.
|
||
player.level, player.atk, player.def_ = 20, 200, 100
|
||
player.hp = player.max_hp = 500
|
||
player.mode = Mode.MENU
|
||
player.at_location = "dungeon"
|
||
player.turns_left = 0
|
||
daily = game.world.settings.daily_turns
|
||
|
||
clk.moment = utc(2026, 6, 13, 0, 5)
|
||
out = game.action("Hero", "descend", "", "")
|
||
|
||
assert "too weary" not in out.lower()
|
||
assert player.turns_left == daily - 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Input hygiene chokepoint (the _sanitize helper)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Negative-test discipline (security invariant): to prove the control-char
|
||
# rejection in Game._sanitize has teeth, the implementer temporarily replaced
|
||
# its ``not cleaned.isprintable()`` clause with ``False`` (disabling the
|
||
# check) and confirmed test_join_rejects_control_char_name FAILED — the
|
||
# injected name created a player row and a public event. The clause was then
|
||
# restored. The newline-injection test below is the standing regression for
|
||
# that invariant.
|
||
|
||
|
||
def test_join_rejects_control_char_name(tmp_path: Path, clock: object) -> None:
|
||
"""A bell/control character in a name is refused with the runes line."""
|
||
game = _game(tmp_path, clock)
|
||
out = game.join("Bra\x07ndr")
|
||
assert "strange runes" in out
|
||
assert game.players == {} # no row created
|
||
assert game.events == [] # nothing persisted
|
||
|
||
|
||
def test_join_rejects_newline_name_no_persist(tmp_path: Path, clock: object) -> None:
|
||
"""An embedded newline (log-injection vector) is refused, nothing written.
|
||
|
||
The name is kept short so it is the control-char clause — not the length
|
||
clause — that rejects it; this is the standing regression for the
|
||
isprintable security invariant documented in the module docstring.
|
||
"""
|
||
game = _game(tmp_path, clock)
|
||
out = game.join("Bra\nndr") # 7 chars: well under the 24 limit
|
||
assert "strange runes" in out # the runes (bad-character) refusal, not length
|
||
# The security invariant: no player row and no event row escaped the guard.
|
||
assert game.players == {}
|
||
assert game.events == []
|
||
|
||
|
||
def test_join_rejects_overlong_name(tmp_path: Path, clock: object) -> None:
|
||
"""A 25-character name is refused with the narrow-ledger line."""
|
||
game = _game(tmp_path, clock)
|
||
out = game.join("X" * 25)
|
||
assert "ledger is narrow" in out
|
||
assert game.players == {}
|
||
|
||
|
||
def test_join_accepts_max_length_name(tmp_path: Path, clock: object) -> None:
|
||
"""A 24-character name is exactly at the limit and accepted."""
|
||
game = _game(tmp_path, clock)
|
||
name = "X" * 24
|
||
game.join(name)
|
||
assert name in game.players
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Narrow-ledger width rule (the _sanitize one-column clause, v0.6)
|
||
#
|
||
# Names/reasons/mail render inside fixed-width frames and tables, so a glyph
|
||
# that does not fit a single column would shove a column out of true. The
|
||
# sanitizer rejects wide runes and combining marks; a printable-but-wide name
|
||
# gets the dedicated narrow-ledger refusal, not the control-char "runes" line.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_join_rejects_wide_cjk_name(tmp_path: Path, clock: object) -> None:
|
||
"""A CJK ideograph name is refused with the narrow-ledger line; nothing written."""
|
||
game = _game(tmp_path, clock)
|
||
out = game.join("龍")
|
||
assert "columns are narrow" in out
|
||
assert game.players == {}
|
||
assert game.events == []
|
||
|
||
|
||
def test_join_rejects_emoji_name(tmp_path: Path, clock: object) -> None:
|
||
"""An emoji in a name (🌲x) is wide and refused with the narrow-ledger line."""
|
||
game = _game(tmp_path, clock)
|
||
out = game.join("🌲x")
|
||
assert "columns are narrow" in out
|
||
assert game.players == {}
|
||
|
||
|
||
def test_join_rejects_fullwidth_name(tmp_path: Path, clock: object) -> None:
|
||
"""A fullwidth Latin letter (A) is two columns and refused."""
|
||
game = _game(tmp_path, clock)
|
||
out = game.join("A")
|
||
assert "columns are narrow" in out
|
||
assert game.players == {}
|
||
|
||
|
||
def test_join_rejects_combining_mark_name(tmp_path: Path, clock: object) -> None:
|
||
"""A name with a combining mark (decomposed accent) is refused as wide.
|
||
|
||
The name is normalised to NFD so the 'o' carries a separate U+0308
|
||
combining diaeresis — a zero-width code point that desynchronises the
|
||
column count. Built explicitly so the source encoding cannot mask it.
|
||
"""
|
||
game = _game(tmp_path, clock)
|
||
decomposed = unicodedata.normalize("NFD", "Bj\u00f6rn")
|
||
assert any(unicodedata.combining(ch) for ch in decomposed) # genuinely NFD
|
||
out = game.join(decomposed)
|
||
assert "columns are narrow" in out
|
||
assert game.players == {}
|
||
|
||
|
||
def test_join_accepts_composed_latin_name(tmp_path: Path, clock: object) -> None:
|
||
"""A precomposed Latin accent (NFC name) is all single-column and accepted."""
|
||
game = _game(tmp_path, clock)
|
||
composed = unicodedata.normalize("NFC", "Bj\u00f6rn")
|
||
game.join(composed)
|
||
assert composed in game.players
|
||
|
||
|
||
def _seed_wide_named_player(db: Path, clock: object, wide_name: str) -> None:
|
||
"""Write a stored adventurer whose name is a now-illegal wide rune.
|
||
|
||
Bypasses ``join`` (which would refuse a wide name at creation) by upserting
|
||
a Player row straight through the Store, so the fixture stands in for a save
|
||
that predates the narrow-ledger rule. Built by renaming a legitimately-
|
||
created hero so every other field stays valid.
|
||
"""
|
||
from dataclasses import replace
|
||
|
||
world = load_world(PACK)
|
||
seed = Store(db)
|
||
game = Game(world, seed, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
|
||
game.join("Brandr")
|
||
base = game.players["Brandr"]
|
||
seed.upsert_player(replace(base, name=wide_name))
|
||
seed.commit()
|
||
seed.close()
|
||
|
||
|
||
def test_join_resumes_stored_wide_name(tmp_path: Path, clock: object) -> None:
|
||
"""An existing adventurer with a wide-rune name resumes \u2014 identity is never re-gated.
|
||
|
||
Resume keys off the exact stored name BEFORE the sanitizer, so a character
|
||
whose name predates the narrow-ledger rule is welcomed back rather than
|
||
locked out. This is the resume-by-exact-name invariant.
|
||
"""
|
||
db = tmp_path / "game.db"
|
||
wide = "\u9f8d"
|
||
_seed_wide_named_player(db, clock, wide)
|
||
|
||
world = load_world(PACK)
|
||
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
|
||
out = game.join(wide)
|
||
assert "Welcome back" in out # resumed, not refused
|
||
assert "columns are narrow" not in out
|
||
assert wide in game.players
|
||
|
||
|
||
def test_join_still_refuses_new_wide_name(tmp_path: Path, clock: object) -> None:
|
||
"""Creation is still gated: a NEW wide name with no stored row is refused.
|
||
|
||
The resume bypass is exact-name only; a wide name that matches no stored
|
||
adventurer falls through to the creation gate and gets the narrow-ledger
|
||
refusal, with nothing written.
|
||
"""
|
||
db = tmp_path / "game.db"
|
||
# Seed one wide-named save, then try to CREATE a different wide name.
|
||
_seed_wide_named_player(db, clock, "\u9f8d")
|
||
|
||
world = load_world(PACK)
|
||
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
|
||
out = game.join("\u7363") # a different wide rune \u2014 no stored row for it
|
||
assert "columns are narrow" in out
|
||
assert "\u7363" not in game.players
|
||
|
||
|
||
def test_bestow_rejects_newline_reason_no_persist(tmp_path: Path, clock: object) -> None:
|
||
"""A newline-embedded bestow reason is refused; no event, pool unchanged."""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
events_before = len(game.events)
|
||
out = game.bestow("Brandr", "heroics\nand a forged log line", 10, 0)
|
||
assert "plainly-spoken" in out
|
||
assert len(game.events) == events_before # no bestow event appended
|
||
assert player.bestow_spent == 0 # pool untouched
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Bestow: heal-only at full HP grants nothing (no empty grant persisted)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_bestow_heal_only_at_full_hp_refused(tmp_path: Path, clock: object) -> None:
|
||
"""A heal-only bestow at full HP applies nothing and must not persist."""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
assert player.hp == player.max_hp # join starts at full health
|
||
events_before = len(game.events)
|
||
out = game.bestow("Brandr", "a quiet blessing", 0, 10)
|
||
assert "already hale" in out
|
||
assert len(game.events) == events_before # no "Fortune favours" line written
|
||
assert player.bestow_spent == 0 # nothing charged
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Descend the deep: one rung per descent (see test_descend.py for the ladder)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_descend_fights_one_rung_and_advances(tmp_path: Path, clock: object) -> None:
|
||
"""A strong player clears the next rung: one foe fought, rewards banked, depth +1."""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Hero")
|
||
player = game.players["Hero"]
|
||
player.level, player.atk, player.def_ = 20, 200, 100
|
||
player.hp = player.max_hp = 500
|
||
player.mode = Mode.MENU
|
||
player.at_location = "dungeon"
|
||
before_turns, before_gold, before_xp = player.turns_left, player.gold, player.xp
|
||
|
||
out = game.action("Hero", "descend", "", "")
|
||
|
||
# The first rung is the tier-3 guardian (Forest Wolf); deeper rungs do NOT
|
||
# appear in one descent — the deep is fought a rung at a time now.
|
||
assert "Forest Wolf" in out
|
||
assert "Cave Troll" not in out
|
||
assert player.deepest_rung == 1
|
||
assert player.turns_left == before_turns - 1
|
||
assert player.gold > before_gold
|
||
assert player.xp > before_xp
|
||
|
||
|
||
def test_descend_bounces_weak_player_to_spawn(tmp_path: Path, clock: object) -> None:
|
||
"""A fresh weak player falls on the first rung and wakes at the spawn.
|
||
|
||
Depth is NOT advanced by a loss, but it persists at whatever it was (here 0).
|
||
"""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Weakling")
|
||
player = game.players["Weakling"]
|
||
player.mode = Mode.MENU
|
||
player.at_location = "dungeon"
|
||
|
||
out = game.action("Weakling", "descend", "", "")
|
||
|
||
assert player.hp == 1
|
||
assert player.mode is Mode.TILE
|
||
assert player.at_location == ""
|
||
assert (player.x, player.y) == game.world.spawn
|
||
assert player.deepest_rung == 0 # a loss never advances the deep
|
||
# Felled by the first rung (the tier-3 Forest Wolf).
|
||
assert "Forest Wolf" in out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shop façade: buy / upgrade / sell / heal stat arithmetic
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_shop_buy_upgrade_sell_heal_cycle(tmp_path: Path, clock: object) -> None:
|
||
"""Equip deltas apply once on buy/upgrade and unwind cleanly on sell."""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
player.gold = 1000
|
||
player.mode = Mode.MENU
|
||
player.at_location = "shop"
|
||
|
||
short_sword = game.world.item_by_id("short_sword")
|
||
war_axe = game.world.item_by_id("war_axe")
|
||
starter = game.world.item_by_id(game.world.settings.starting_weapon)
|
||
assert short_sword is not None and war_axe is not None and starter is not None
|
||
|
||
starter_atk = player.atk # 3 base + rusty dagger bonus
|
||
|
||
# Buy the short sword: gold falls by its price, atk rises by the delta.
|
||
gold0 = player.gold
|
||
game.action("Brandr", "buy", "", "short_sword")
|
||
assert player.gold == gold0 - short_sword.price
|
||
assert player.atk == starter_atk + (short_sword.atk - starter.atk)
|
||
atk_with_sword = player.atk
|
||
|
||
# Upgrade to the war axe: atk reflects the difference, not a double-add.
|
||
gold1 = player.gold
|
||
game.action("Brandr", "buy", "", "war_axe")
|
||
assert player.gold == gold1 - war_axe.price
|
||
assert player.atk == atk_with_sword + (war_axe.atk - short_sword.atk)
|
||
|
||
# Sell the war axe: half-price refund, atk falls back to the starter bonus.
|
||
gold2 = player.gold
|
||
game.action("Brandr", "sell", "", "")
|
||
assert player.gold == gold2 + war_axe.price // 2
|
||
assert player.atk == starter_atk
|
||
|
||
# Heal at the shrine: HP restored, gold debited per missing point.
|
||
player.mode = Mode.MENU
|
||
player.at_location = "healer"
|
||
player.hp = player.max_hp - 5
|
||
per_hp = game.world.settings.heal_cost_per_hp
|
||
gold3 = player.gold
|
||
game.action("Brandr", "heal", "", "")
|
||
assert player.hp == player.max_hp
|
||
assert player.gold == gold3 - 5 * per_hp
|
||
|
||
|
||
def test_sell_starter_weapon_refused(tmp_path: Path, clock: object) -> None:
|
||
"""The starter blade is unsellable regardless of price (no free-gold loop)."""
|
||
game = _game(tmp_path, clock)
|
||
game.join("Brandr")
|
||
player = game.players["Brandr"]
|
||
assert player.weapon_id == game.world.settings.starting_weapon
|
||
player.mode = Mode.MENU
|
||
player.at_location = "shop"
|
||
gold_before = player.gold
|
||
out = game.action("Brandr", "sell", "", "")
|
||
assert "nothing worth selling" in out.lower()
|
||
assert player.gold == gold_before
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Bounded in-memory event tail (full history stays in SQLite)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_event_tail_is_capped_but_log_still_works(tmp_path: Path, clock: object) -> None:
|
||
"""Loading caps the resident tail; door_log still serves recent events."""
|
||
from understone.engine.log import since
|
||
from understone.game import EVENT_TAIL_KEEP
|
||
|
||
db = tmp_path / "game.db"
|
||
seed_store = Store(db)
|
||
last_id = 0
|
||
for i in range(EVENT_TAIL_KEEP + 50):
|
||
last_id = seed_store.insert_event("t", "sys", "note", f"event {i}")
|
||
seed_store.commit()
|
||
seed_store.close()
|
||
|
||
world = load_world(PACK)
|
||
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
|
||
# Only the most recent EVENT_TAIL_KEEP events are resident in memory.
|
||
assert len(game.events) == EVENT_TAIL_KEEP
|
||
assert game.events[-1].event_id == last_id
|
||
|
||
# door_log still reports events after a recent cursor.
|
||
recent_cursor = game.events[-3].event_id
|
||
game.join("Brandr")
|
||
game.players["Brandr"].log_cursor = recent_cursor
|
||
out = game.log("Brandr")
|
||
assert "The Understone Herald" in out # broadsheet masthead
|
||
assert "since your last visit" in out
|
||
fresh, new_cursor = since(game.events, recent_cursor)
|
||
assert fresh # there are events past the cursor
|
||
assert new_cursor == game.events[-1].event_id
|
||
|
||
|
||
def test_private_mail_survives_tail_eviction(tmp_path: Path, clock: object) -> None:
|
||
"""A private note older than the resident tail is still delivered (durable mail).
|
||
|
||
Public history that falls off the in-memory tail is gone by design (the
|
||
broadsheet does not keep), but mail must not be: a note left while the
|
||
recipient was away has to surface however many public events have since
|
||
pushed it out of the tail. A third player — whose cursor also predates the
|
||
note — must still never see it, because it was never theirs.
|
||
"""
|
||
from understone.persistence import EVENT_TAIL_KEEP
|
||
|
||
db = tmp_path / "game.db"
|
||
store = Store(db)
|
||
game = Game(load_world(PACK), store, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
|
||
game.join("Scribe")
|
||
game.join("Reader")
|
||
game.join("Bystander")
|
||
# Scribe leaves Reader a private note; neither Reader nor Bystander reads it.
|
||
secret = "the cellar key is under the third barrel"
|
||
game.action("Scribe", "post", "Reader", "", secret)
|
||
|
||
# Flood the feed past the tail bound so the note is evicted from memory.
|
||
for i in range(EVENT_TAIL_KEEP + 20):
|
||
store.insert_event("t", "sys", "note", f"broadsheet filler {i}")
|
||
store.commit()
|
||
store.close()
|
||
|
||
# Reopen: only the newest tail is resident, so the note now lives in the gap.
|
||
reopened = Store(db)
|
||
revived = Game(load_world(PACK), reopened, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
|
||
note_id = next(
|
||
e.event_id
|
||
for e in reopened.targeted_events_since("Reader", 0) # note: from SQLite, not the tail
|
||
if secret in e.text
|
||
)
|
||
assert note_id < revived.events[0].event_id # the note really is past the tail
|
||
|
||
# The recipient still sees the note, backfilled from SQLite...
|
||
reader_log = revived.log("Reader")
|
||
assert secret in reader_log
|
||
assert "While you were away" in reader_log
|
||
# ...but a third player never does, even though their cursor predates it too.
|
||
third_log = revived.log("Bystander")
|
||
assert secret not in third_log
|
||
reopened.close()
|