From 08d46f086fcba06ea3a7505df3e7f7159a6d089e Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 12 Jun 2026 18:45:53 -0700 Subject: [PATCH] =?UTF-8?q?feat(examples):=20Understone=20v0.5=20=E2=80=94?= =?UTF-8?q?=20ambushes,=20the=20inn=20mailbox,=20and=20dice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The social slice: the shared world gets teeth, letters, and a house game. - Ambush (async PvP, classic door-game player-kill spirit): waylay an adventurer who has not yet begun their day. Ordered gates — known target, not yourself, the gatekeeper shields the young (both >= min level), level band +-2, the SLEEP RULE (acting today makes you watchful — an active-play defense), mercy for the downed (hp<=1 cannot be piled on: even bandits have standards), once per pair per UTC day. Win: capped gold cut transfers, victim wakes at the spawn-stone with a private note; lose: the sleeper wakes blade-in-hand and the Herald crows your shame. The attacker wears the counter-blows the combat log narrates (state matches story). Both players persist in one transaction. - The inn mailbox: events carry a target ('' = public). door_log delivers private notes to the addressee only; the Watch and other players never see them. Mail is DURABLE past the in-memory tail (SQLite backfill for cursors older than the resident window) — the broadsheet is ephemeral, letters are not. Sanitized, daily-capped. - Inn dice: 2d6 against the house, bet- and count-capped per day, big wins make the news. - Six new banded settings; four day-counter columns join the shared lazy UTC reset; schema stamp stays 1 (pre-1.0 mutates in place by design). Tests 184 -> 231; sleep rule, mercy gate, band boundary (exact/over), refusal precedence, attacker wear, zero-gold robbery, mail eviction survival, and Watch privacy all pinned; guards revert-verified. --- examples/door-game/README.md | 10 +- examples/door-game/pyproject.toml | 2 +- examples/door-game/tests/conftest.py | 16 + examples/door-game/tests/test_game.py | 47 ++ examples/door-game/tests/test_package.py | 2 +- examples/door-game/tests/test_persistence.py | 51 ++ examples/door-game/tests/test_social.py | 741 ++++++++++++++++++ examples/door-game/tests/test_turns.py | 28 +- examples/door-game/tests/test_world_loader.py | 63 ++ examples/door-game/understone/__init__.py | 2 +- examples/door-game/understone/engine/log.py | 29 +- .../door-game/understone/engine/models.py | 10 + examples/door-game/understone/engine/turns.py | 21 +- examples/door-game/understone/game.py | 411 +++++++++- examples/door-game/understone/persistence.py | 86 +- examples/door-game/understone/server.py | 62 +- examples/door-game/understone/watch.py | 13 +- .../understone/world/data/locations.json | 5 +- .../understone/world/data/world.json | 8 +- examples/door-game/understone/world/loader.py | 12 + 20 files changed, 1538 insertions(+), 81 deletions(-) create mode 100644 examples/door-game/tests/test_social.py diff --git a/examples/door-game/README.md b/examples/door-game/README.md index 631ff57d..8a761411 100644 --- a/examples/door-game/README.md +++ b/examples/door-game/README.md @@ -48,6 +48,14 @@ A run is a little RPG loop, played a bit each day: - **Read the news.** `door_log` is the **Understone Herald**, a shared broadsheet of notable deeds across the whole world — who joined, who rose a level, who was dragged home by a goblin, and who freed the Vale. +- **Make it social.** It is a shared world, so you can touch other players. + `ambush` a rival who has not yet acted today — a classic + style player-kill that robs a sleeping foe of some gold, except the surest + defence is simply to take your own turn (an active player is awake and can't + be caught). Lose the ambush and *you* are the one who flees, shamed on the + feed. `post` a private note another player reads on their next visit (it + never reaches the public Herald). Or `gamble` a little gold at the inn's dice + against the house. Ambush spends a turn; mail and dice do not. ## Installation @@ -207,7 +215,7 @@ url = "http://localhost:8077/mcp" | `door_status` | The character sheet (read-only). | | `door_look` | Redraw the current view — overworld map or location menu. | | `door_move` | Walk the overworld (free; no daily turn spent). | -| `door_action` | Context verbs: fight, flee, rest, buy, sell, heal, descend, challenge (the Wyrm), leave. | +| `door_action` | Context verbs: fight, flee, ambush (a rival), rest, buy, sell, heal, gamble (inn dice), descend, challenge (the Wyrm), post (mail another player), leave. | | `door_log` | The Understone Herald — the shared feed of notable deeds. | | `door_rank` | The leaderboard, plus the Hall of Legends (★ marks Wyrm kills). | | `door_bestow` | Game-master grant of a little gold/healing for a story beat. | diff --git a/examples/door-game/pyproject.toml b/examples/door-game/pyproject.toml index 19cbcb9d..1489c02d 100644 --- a/examples/door-game/pyproject.toml +++ b/examples/door-game/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "understone" -version = "0.4.0" +version = "0.5.0" description = "Understone — a BBS-style ANSI door game served over MCP." requires-python = ">=3.11" license = "Apache-2.0" diff --git a/examples/door-game/tests/conftest.py b/examples/door-game/tests/conftest.py index 35079aad..709baf7f 100644 --- a/examples/door-game/tests/conftest.py +++ b/examples/door-game/tests/conftest.py @@ -58,6 +58,12 @@ DEFAULT_SETTINGS = Settings( dungeon_tiers=(4, 5), boss_monster="wyrm_below", wyrm_min_level=6, + ambush_min_level=3, + ambush_level_band=2, + ambush_gold_pct=25, + post_daily_cap=5, + gamble_max_bet=50, + gamble_daily_cap=5, ) @@ -81,6 +87,12 @@ def make_settings(**overrides: object) -> Settings: "dungeon_tiers": DEFAULT_SETTINGS.dungeon_tiers, "boss_monster": DEFAULT_SETTINGS.boss_monster, "wyrm_min_level": DEFAULT_SETTINGS.wyrm_min_level, + "ambush_min_level": DEFAULT_SETTINGS.ambush_min_level, + "ambush_level_band": DEFAULT_SETTINGS.ambush_level_band, + "ambush_gold_pct": DEFAULT_SETTINGS.ambush_gold_pct, + "post_daily_cap": DEFAULT_SETTINGS.post_daily_cap, + "gamble_max_bet": DEFAULT_SETTINGS.gamble_max_bet, + "gamble_daily_cap": DEFAULT_SETTINGS.gamble_daily_cap, } base.update(overrides) return Settings(**base) # type: ignore[arg-type] @@ -111,6 +123,10 @@ def make_player(**overrides: object) -> Player: "bestow_spent": 0, "bestow_day": 0, "wins": 0, + "posts_sent": 0, + "post_day": 0, + "gambles": 0, + "gamble_day": 0, } fields.update(overrides) return Player(**fields) # type: ignore[arg-type] diff --git a/examples/door-game/tests/test_game.py b/examples/door-game/tests/test_game.py index e6c3177e..23ea18d1 100644 --- a/examples/door-game/tests/test_game.py +++ b/examples/door-game/tests/test_game.py @@ -636,3 +636,50 @@ def test_event_tail_is_capped_but_log_still_works(tmp_path: Path, clock: object) 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() diff --git a/examples/door-game/tests/test_package.py b/examples/door-game/tests/test_package.py index f90324ab..a6f9ec82 100644 --- a/examples/door-game/tests/test_package.py +++ b/examples/door-game/tests/test_package.py @@ -6,4 +6,4 @@ import understone def test_version_present() -> None: - assert understone.__version__ == "0.4.0" + assert understone.__version__ == "0.5.0" diff --git a/examples/door-game/tests/test_persistence.py b/examples/door-game/tests/test_persistence.py index 0cfd39b4..800d43ea 100644 --- a/examples/door-game/tests/test_persistence.py +++ b/examples/door-game/tests/test_persistence.py @@ -60,6 +60,10 @@ def test_player_round_trip_all_columns(tmp_path: Path) -> None: log_cursor=42, bestow_spent=15, bestow_day=739_400, + posts_sent=3, + post_day=739_400, + gambles=2, + gamble_day=739_400, ) store.upsert_player(player) store.commit() @@ -76,9 +80,56 @@ def test_player_round_trip_all_columns(tmp_path: Path) -> None: assert loaded.bestow_spent == 15 assert loaded.bestow_day == 739_400 assert loaded.mode is Mode.MENU + # The v0.5 social columns survive the round-trip too. + assert loaded.posts_sent == 3 + assert loaded.post_day == 739_400 + assert loaded.gambles == 2 + assert loaded.gamble_day == 739_400 reopened.close() +def test_event_target_round_trips(tmp_path: Path) -> None: + """A targeted (private) event keeps its target across a reopen; public is ''.""" + store = _store(tmp_path) + pub = store.insert_event("t1", "Brandr", "join", "set out") + priv = store.insert_event("t2", "Sigrun", "ambushed", "robbed in your sleep", "Brandr") + store.commit() + store.close() + + reopened = _store(tmp_path) + _, events = reopened.load_all() + by_id = {e.event_id: e for e in events} + assert by_id[pub].target == "" # public stays empty + assert by_id[priv].target == "Brandr" # private keeps its recipient + reopened.close() + + +def test_ambush_table_per_day_uniqueness(tmp_path: Path) -> None: + """The ambushes PK is (attacker, target, day): one row per pair per day.""" + store = _store(tmp_path) + day = 739_400 + assert store.has_ambushed("Brandr", "Sigrun", day) is False + store.record_ambush("Brandr", "Sigrun", day) + store.commit() + assert store.has_ambushed("Brandr", "Sigrun", day) is True + # A second record for the same pair/day is a no-op (INSERT OR IGNORE): + # the duplicate must not raise and must not add a row. + store.record_ambush("Brandr", "Sigrun", day) + store.commit() + rows = store._conn.execute( + "SELECT COUNT(*) AS n FROM ambushes WHERE attacker=? AND target=? AND day=?", + ("Brandr", "Sigrun", day), + ).fetchone() + assert rows["n"] == 1 + # A new day is a fresh attempt; the old day stays recorded. + assert store.has_ambushed("Brandr", "Sigrun", day + 1) is False + store.record_ambush("Brandr", "Sigrun", day + 1) + store.commit() + assert store.has_ambushed("Brandr", "Sigrun", day) is True + assert store.has_ambushed("Brandr", "Sigrun", day + 1) is True + store.close() + + def test_upsert_updates_existing_row(tmp_path: Path) -> None: store = _store(tmp_path) player = make_player(name="Sigrun", gold=10) diff --git a/examples/door-game/tests/test_social.py b/examples/door-game/tests/test_social.py new file mode 100644 index 00000000..96afc347 --- /dev/null +++ b/examples/door-game/tests/test_social.py @@ -0,0 +1,741 @@ +"""The v0.5 social slice — ambush (async PvP), inn mail, and inn dice. + +Drives the game façade over the shipped world with a frozen clock and a seeded +RNG. Three feature areas: + +* AMBUSH — the full eligibility matrix (every refusal branch), the win path + (exact gold transfer, victim bounced to spawn at 1 HP, private mail visible + only to the victim, public news), the lose path (attacker bounced, no + transfer), the flee stalemate, per-day once-per-pair, and next-day retry. +* MAIL — ``post`` delivers a private note to the target's log once, the sender + is confirmed, the daily cap refuses the overflow, the sanitizer rejects a + newline body, and the Watch state payload NEVER carries a targeted row. +* DICE — win/lose/push under a seeded RNG, the bet band, affordability, the + daily cap (a push still counts), and the Herald firing only on a big win. + +Negative-test discipline (the SLEEP RULE has teeth): + ``test_sleep_rule_guard_has_teeth`` documents the revert-and-observe check. + Disabling the ``target.turn_day >= today`` clause in Game._ambush_refusal + let an ALREADY-AWAKE target be ambushed — ``test_ambush_refused_target_awake`` + then failed (the attempt resolved instead of being refused). The clause was + restored; that refusal test is the standing regression for the invariant. +""" + +from __future__ import annotations + +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.watch import build_state_payload +from understone.world.loader import load_world + +PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data" + +# The frozen "today" all these tests run on; the sleep rule keys off its ordinal. +_NOW = utc(2026, 6, 12, 10, 0) +_TODAY = _NOW.toordinal() + + +@pytest.fixture +def clock() -> object: + return fixed_clock(_NOW) + + +def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game: + world = load_world(PACK) + store = Store(tmp_path / "social.db") + return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type] + + +def _arm_ambush( + game: Game, + *, + attacker_level: int = 5, + target_level: int = 5, + target_asleep: bool = True, + target_gold: int = 100, +) -> tuple[object, object]: + """Join an attacker + target and tune their sheets for an ambush. + + The attacker is overworld and seasoned; the target sits at *target_level* + with *target_gold*, and ``target_asleep`` controls the sleep rule (a + sleeping target has not acted today). Returns ``(attacker, target)``. + """ + game.join("Raider") + game.join("Sleeper") + attacker = game.players["Raider"] + target = game.players["Sleeper"] + attacker.level = attacker_level + target.level = target_level + target.gold = target_gold + target.turn_day = _TODAY - 1 if target_asleep else _TODAY + return attacker, target + + +# --------------------------------------------------------------------------- +# Ambush — eligibility matrix (each refusal is a distinct in-fiction line) +# --------------------------------------------------------------------------- + + +def test_ambush_refused_unknown_target(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + game.join("Raider") + game.players["Raider"].level = 5 + out = game.action("Raider", "ambush", "Ghost", "") + assert "signed the ledger" in out # the unknown-player refusal + # No turn spent on an unresolvable target. + assert game.players["Raider"].turns_left == game.world.settings.daily_turns + + +def test_ambush_refused_self(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + game.join("Raider") + game.players["Raider"].level = 5 + out = game.action("Raider", "ambush", "Raider", "") + assert "yourself" in out.lower() + + +def test_ambush_refused_young_attacker(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + floor = game.world.settings.ambush_min_level + _arm_ambush(game, attacker_level=floor - 1, target_level=floor + 1) + out = game.action("Raider", "ambush", "Sleeper", "") + assert "shields the young" in out + assert game.players["Raider"].turns_left == game.world.settings.daily_turns + + +def test_ambush_refused_young_target(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + floor = game.world.settings.ambush_min_level + # Attacker is seasoned but the target is below the floor: still shielded. + _arm_ambush(game, attacker_level=floor + 1, target_level=floor - 1) + out = game.action("Raider", "ambush", "Sleeper", "") + assert "shields the young" in out + + +def test_ambush_refused_out_of_band(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + band = game.world.settings.ambush_level_band + floor = game.world.settings.ambush_min_level + _arm_ambush( + game, + attacker_level=floor + band + 5, + target_level=floor, + ) + out = game.action("Raider", "ambush", "Sleeper", "") + assert "far from your measure" in out + + +def test_ambush_band_beats_awake_in_refusal_order(tmp_path: Path, clock: object) -> None: + """PRECEDENCE: the band gate is checked before the sleep rule. + + A target who is BOTH out of band AND awake must report the band message, + not the watchful one — pinning the documented order (level gates before the + live-play sleep defence). + """ + game = _game(tmp_path, clock) + band = game.world.settings.ambush_level_band + floor = game.world.settings.ambush_min_level + _arm_ambush( + game, + attacker_level=floor + band + 1, # one past the band... + target_level=floor, + target_asleep=False, # ...and also awake + ) + out = game.action("Raider", "ambush", "Sleeper", "") + assert "far from your measure" in out # the band gate wins + assert "watchful today" not in out + + +def test_ambush_band_boundary_exact_is_allowed(tmp_path: Path, clock: object) -> None: + """Exactly ``ambush_level_band`` apart clears the band gate (it is inclusive). + + Armed awake so the very next gate — the sleep rule — is what speaks: a + 'watchful today' refusal proves the band gate let this pair through. + """ + game = _game(tmp_path, clock) + band = game.world.settings.ambush_level_band + floor = game.world.settings.ambush_min_level + _arm_ambush( + game, + attacker_level=floor + band, # exactly band levels above the floor + target_level=floor, + target_asleep=False, + ) + out = game.action("Raider", "ambush", "Sleeper", "") + assert "far from your measure" not in out # past the band gate + assert "watchful today" in out # stopped by the next gate instead + + +def test_ambush_band_boundary_one_over_is_refused(tmp_path: Path, clock: object) -> None: + """One level past ``ambush_level_band`` is refused with the band message.""" + game = _game(tmp_path, clock) + band = game.world.settings.ambush_level_band + floor = game.world.settings.ambush_min_level + _arm_ambush( + game, + attacker_level=floor + band + 1, # just over the band + target_level=floor, + ) + out = game.action("Raider", "ambush", "Sleeper", "") + assert "far from your measure" in out + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False + + +def test_ambush_refused_target_awake(tmp_path: Path, clock: object) -> None: + """The SLEEP RULE: a target who has already acted today is un-ambushable. + + See the module docstring for the revert-and-observe check proving this + refusal has teeth. + """ + game = _game(tmp_path, clock) + _arm_ambush(game, target_asleep=False) + out = game.action("Raider", "ambush", "Sleeper", "") + assert "watchful today" in out + # Refused without resolving: no turn spent, no ambush recorded. + assert game.players["Raider"].turns_left == game.world.settings.daily_turns + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False + + +def test_ambush_refused_repeat_same_pair_same_day(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game) + # First attempt resolves (attacker overwhelming -> a clean win). + attacker.atk = 200 + target.hp = 5 + game.action("Raider", "ambush", "Sleeper", "") + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True + # Re-arm the target as sleeping AND healed above 1 HP (so the mercy rule + # does not intercept first); the SAME pair is still barred for the day. + target.turn_day = _TODAY - 1 + target.hp = 20 + out = game.action("Raider", "ambush", "Sleeper", "") + assert "already lain in wait" in out + + +def test_ambush_refused_pile_on_downed_victim(tmp_path: Path, clock: object) -> None: + """MERCY RULE: a second, DIFFERENT attacker cannot kick a just-bounced sleeper. + + The first ambush leaves the victim at 1 HP (still asleep — being robbed does + not start their day). A fresh raider then finds them battered in the ditch; + even bandits have standards, so the pile-on is refused outright — no turn + spent, no pair-row written for the second attacker. + """ + game = _game(tmp_path, clock) + first, target = _arm_ambush(game, target_gold=100) + first.atk = 200 # one-shot: leaves the victim at 1 HP + target.hp = 5 + game.action("Raider", "ambush", "Sleeper", "") + assert target.hp == 1 # downed and still asleep + + # A second, seasoned raider tries to finish the job. + game.join("Marauder") + second = game.players["Marauder"] + second.level = 5 + turns_before = second.turns_left + out = game.action("Marauder", "ambush", "Sleeper", "") + + assert "battered in the ditch" in out + # No turn spent and no attempt recorded for the second attacker. + assert second.turns_left == turns_before + assert game.store.has_ambushed("Marauder", "Sleeper", _TODAY) is False + + +def test_ambush_healed_victim_is_ambushable_again(tmp_path: Path, clock: object) -> None: + """The mercy rule lifts once the victim mends: healed above 1 HP (and still + asleep), a fresh attacker may strike.""" + game = _game(tmp_path, clock) + first, target = _arm_ambush(game, target_gold=100) + first.atk = 200 + target.hp = 5 + game.action("Raider", "ambush", "Sleeper", "") + assert target.hp == 1 + + # The victim is tended back above the floor (still asleep this day). + target.hp = 18 + game.join("Marauder") + second = game.players["Marauder"] + second.level = 5 + second.atk = 200 # one-shot again + out = game.action("Marauder", "ambush", "Sleeper", "") + + assert "battered in the ditch" not in out + # The fresh ambush resolved: recorded, and the victim is bounced anew. + assert game.store.has_ambushed("Marauder", "Sleeper", _TODAY) is True + assert target.hp == 1 + + +def test_ambush_refused_zero_turns(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + attacker, _ = _arm_ambush(game) + attacker.turns_left = 0 + out = game.action("Raider", "ambush", "Sleeper", "") + assert "spent for today" in out.lower() + # Eligible but exhausted: nothing recorded (the attempt never landed). + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False + + +# --------------------------------------------------------------------------- +# Ambush — outcomes (win / lose / flee) and the records they leave +# --------------------------------------------------------------------------- + + +def test_ambush_win_transfers_gold_and_bounces_victim(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game, target_gold=100) + attacker.atk = 200 # one-shot the sleeper + target.hp = 5 + pct = game.world.settings.ambush_gold_pct + steal = 100 * pct // 100 # 25 gold at the shipped 25% + raider_gold_before = attacker.gold + + out = game.action("Raider", "ambush", "Sleeper", "") + + # Exact transfer: attacker up by steal, victim down by the same. + assert attacker.gold == raider_gold_before + steal + assert target.gold == 100 - steal + # The victim wakes at the spawn at 1 HP, knocked out of any menu. + assert target.hp == 1 + assert (target.x, target.y) == game.world.spawn + assert target.mode is Mode.TILE + assert target.at_location == "" + assert f"{steal} gold" in out + # The attempt is recorded. + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True + + +def test_ambush_win_applies_attacker_wear(tmp_path: Path, clock: object) -> None: + """A multi-round win banks the attacker's wear: the log narrates the + sleeper's counter-blows, so the sheet must show the HP they cost. + + The one-shot win above leaves the attacker untouched, which would mask a + WIN branch that drops ``hp_delta`` on the floor. Here the sleeper is tanky + enough to trade blows before falling (and the attacker still wins), so the + attacker must end below full HP. Stats and seed are tuned so the win is + decisive but not instant. + """ + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game, target_gold=100) + attacker.atk, attacker.def_ = 8, 2 + attacker.hp = attacker.max_hp = 30 + target.atk, target.def_, target.hp = 5, 1, 25 + + out = game.action("Raider", "ambush", "Sleeper", "") + + # The win lands (victim robbed and bounced to 1 HP)... + assert target.hp == 1 + assert ( + any(crow in out for crow in ("made off", "robbed the sleeping", "lifted")) or "rob" in out + ) + # ...but the sleeper's counter-blows cost the attacker real HP this time. + assert attacker.hp < attacker.max_hp + assert attacker.hp >= 1 # never below the floor + + +def test_ambush_win_news_is_public_and_mail_is_private(tmp_path: Path, clock: object) -> None: + """The victory crows on the public feed; the victim gets a PRIVATE note. + + A THIRD player must see the public ambush line but never the private one. + """ + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game, target_gold=80) + attacker.atk = 200 + target.hp = 5 + game.join("Bystander") # a third player who must never see the private note + + game.action("Raider", "ambush", "Sleeper", "") + + # The victim reads the private "While you slept" note in their own log. + victim_log = game.log("Sleeper") + assert "While you slept" in victim_log + assert "ambushed you" in victim_log + + # The bystander sees the public crow but NOT the private note. + third_log = game.log("Bystander") + assert ( + "made off with" in third_log + or "robbed the sleeping" in third_log + or ("lifted" in third_log) + ) + assert "While you slept" not in third_log + + +def test_ambush_win_on_pauper_steals_nothing_but_still_lands(tmp_path: Path, clock: object) -> None: + """A win over a penniless sleeper: steal is 0, but the beat still plays. + + The victim is bounced to the spawn at 1 HP all the same, the public herald + crows the robbery, and the private 'while you slept' note still reaches the + victim — the gold transfer being empty changes none of that. + """ + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game, target_gold=0) + attacker.atk = 200 # one-shot the sleeper + target.hp = 5 + game.join("Bystander") + raider_gold_before = attacker.gold + + out = game.action("Raider", "ambush", "Sleeper", "") + + # Nothing to steal: both purses are unchanged by the transfer. + assert attacker.gold == raider_gold_before + assert target.gold == 0 + assert "0 gold" in out + # The victim is still bounced to the spawn at 1 HP. + assert target.hp == 1 + assert (target.x, target.y) == game.world.spawn + assert target.mode is Mode.TILE + assert target.at_location == "" + + # Public herald fires (a bystander reads the crow)... + third_log = game.log("Bystander") + assert any(crow in third_log for crow in ("made off", "robbed the sleeping", "lifted")) + # ...and the private mail still reaches the victim. + victim_log = game.log("Sleeper") + assert "While you slept" in victim_log + assert "ambushed you" in victim_log + + +def test_ambush_lose_bounces_attacker_no_transfer(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game, target_gold=100) + # The sleeper is deadly: the ambush rebounds onto the attacker. + target.atk = 200 + target.def_ = 100 + target.hp = 200 + attacker_gold_before = attacker.gold + + out = game.action("Raider", "ambush", "Sleeper", "") + + # No gold moved; the ATTACKER is the one bounced to spawn at 1 HP. + assert attacker.gold == attacker_gold_before + assert target.gold == 100 + assert attacker.hp == 1 + assert (attacker.x, attacker.y) == game.world.spawn + assert "flee" in out.lower() or "wakes" in out.lower() + # The attempt is still spent. + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True + + +def test_ambush_records_attempt_on_every_outcome(tmp_path: Path, clock: object) -> None: + """Win, lose, or flee — the (attacker, target, day) row is always written.""" + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game) + # Tune a flee: when neither side can meaningfully dent the other, the fight + # grinds to the 50-round stalemate guard, which resolves as FLED with no + # transfer. Both deal the 1-damage floor (atk << def), and both carry far + # more HP than 50 rounds can drain, so neither drops first. + attacker.atk, attacker.def_ = 1, 200 + attacker.hp = attacker.max_hp = 500 + target.atk, target.def_, target.hp = 1, 200, 500 + gold_before = attacker.gold + + out = game.action("Raider", "ambush", "Sleeper", "") + + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True + assert attacker.gold == gold_before # a flee moves no gold + assert "slip away" in out.lower() or "nerve" in out.lower() + + +def test_ambush_next_day_retry_allowed(tmp_path: Path, clock: object) -> None: + """A new UTC day clears the once-per-pair lock (advance the injected clock).""" + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game) + attacker.atk = 200 + target.hp = 5 + game.action("Raider", "ambush", "Sleeper", "") + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True + + # Advance past UTC midnight; re-arm the sleeper for the new day. + tomorrow = utc(2026, 6, 13, 9, 0) + game.clock = fixed_clock(tomorrow) # type: ignore[assignment] + target.turn_day = tomorrow.toordinal() - 1 # asleep again + target.hp = 5 + out = game.action("Raider", "ambush", "Sleeper", "") + assert "already lain in wait" not in out # the new day permits a fresh attempt + assert game.store.has_ambushed("Raider", "Sleeper", tomorrow.toordinal()) is True + + +def test_sleep_rule_guard_has_teeth(tmp_path: Path, clock: object) -> None: + """Pin the sleep rule on a single-field divergence. + + The un-ambushable case and the ambushable case differ ONLY in ``turn_day``: + with the target awake the action is refused, and flipping that one field to + asleep makes the very same attempt resolve and record. + """ + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game, target_asleep=False) + attacker.atk = 200 + target.hp = 5 + refused = game.action("Raider", "ambush", "Sleeper", "") + assert "watchful today" in refused + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is False + + # Flip ONLY the sleep field; now the very same attempt lands. + target.turn_day = _TODAY - 1 + resolved = game.action("Raider", "ambush", "Sleeper", "") + assert "watchful today" not in resolved + assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True + + +def test_ambush_both_rows_persist_in_one_transaction(tmp_path: Path, clock: object) -> None: + """A win commits BOTH fighters' rows; a store reopen sees the transfer.""" + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game, target_gold=100) + attacker.atk = 200 + target.hp = 5 + game.action("Raider", "ambush", "Sleeper", "") + raider_gold = attacker.gold + sleeper_gold = target.gold + game.store.close() + + world = load_world(PACK) + reopened = Store(tmp_path / "social.db") + revived = Game(world, reopened, clock=clock) # type: ignore[arg-type] + assert revived.players["Raider"].gold == raider_gold + assert revived.players["Sleeper"].gold == sleeper_gold + assert revived.players["Sleeper"].hp == 1 + reopened.close() + + +# --------------------------------------------------------------------------- +# Mail — post delivers privately, confirms, caps, sanitizes +# --------------------------------------------------------------------------- + + +def test_post_delivers_to_target_once_with_confirmation(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + game.join("Scribe") + game.join("Reader") + confirm = game.action("Scribe", "post", "Reader", "", "meet me at the inn") + assert "tucks the note" in confirm # the sender's in-fiction confirmation + # No turn spent on a post. + assert game.players["Scribe"].turns_left == game.world.settings.daily_turns + + first = game.log("Reader") + assert "While you were away" in first + assert "meet me at the inn" in first + # Read once: the cursor advanced, so a second read no longer shows it. + second = game.log("Reader") + assert "meet me at the inn" not in second + + +def test_post_refused_unknown_and_self(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + game.join("Scribe") + unknown = game.action("Scribe", "post", "Nobody", "", "hello?") + assert "signed the ledger" in unknown + mine = game.action("Scribe", "post", "Scribe", "", "note to self") + assert "talk to yourself" in mine.lower() + + +def test_post_daily_cap_refuses_overflow(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + game.join("Scribe") + game.join("Reader") + cap = game.world.settings.post_daily_cap + for i in range(cap): + out = game.action("Scribe", "post", "Reader", "", f"note {i}") + assert "tucks the note" in out + # The (cap+1)-th post is refused. + over = game.action("Scribe", "post", "Reader", "", "one too many") + assert "all the word you may today" in over + assert game.players["Scribe"].posts_sent == cap + + +def test_post_sanitizer_rejects_newline_body(tmp_path: Path, clock: object) -> None: + """A newline-injected note body is refused; nothing is delivered or counted.""" + game = _game(tmp_path, clock) + game.join("Scribe") + game.join("Reader") + events_before = len(game.events) + out = game.action("Scribe", "post", "Reader", "", "line one\nFORGED HERALD LINE") + assert "scrawl" in out.lower() + # No event appended and the daily counter is untouched. + assert len(game.events) == events_before + assert game.players["Scribe"].posts_sent == 0 + # And the reader never receives it. + assert "FORGED" not in game.log("Reader") + + +def test_post_works_from_inside_a_building(tmp_path: Path, clock: object) -> None: + """Posting is legal anywhere: a menu-bound sender still gets a menu reply.""" + game = _game(tmp_path, clock) + game.join("Scribe") + game.join("Reader") + scribe = game.players["Scribe"] + scribe.mode = Mode.MENU + scribe.at_location = "inn" + out = game.action("Scribe", "post", "Reader", "", "by the hearth") + assert "tucks the note" in out + # The reply is the inn menu (a menu surface), not an overworld frame. + assert "(R)est" in out or "Sleeping Drake" in out + + +# --------------------------------------------------------------------------- +# Mail — the lobby TV must never carry a private note +# --------------------------------------------------------------------------- + + +def test_watch_state_excludes_targeted_rows(tmp_path: Path, clock: object) -> None: + """EXPLICIT: a private (targeted) event must not reach the Watch herald.""" + game = _game(tmp_path, clock) + game.join("Scribe") + game.join("Reader") + game.action("Scribe", "post", "Reader", "", "a secret for the Reader") + + payload = build_state_payload(game) + herald = payload["herald"] + assert isinstance(herald, list) + texts = [row["text"] for row in herald] + # The join lines are public and present; the private note is absent. + assert any("Scribe" in t or "Reader" in t for t in texts) # public joins show + assert all("a secret for the Reader" not in t for t in texts) + + +def test_watch_state_excludes_private_ambush_note(tmp_path: Path, clock: object) -> None: + """The ambush victim's private alert is filtered from the lobby TV too.""" + game = _game(tmp_path, clock) + attacker, target = _arm_ambush(game, target_gold=80) + attacker.atk = 200 + target.hp = 5 + game.action("Raider", "ambush", "Sleeper", "") + + herald_texts = [row["text"] for row in build_state_payload(game)["herald"]] # type: ignore[union-attr] + # The PUBLIC ambush crow is on the feed... + assert any( + "Sleeper" in t and ("made off" in t or "robbed" in t or "lifted" in t) for t in herald_texts + ) + # ...but the PRIVATE "While you slept" note never is. + assert all("While you slept" not in t for t in herald_texts) + + +# --------------------------------------------------------------------------- +# Dice — win / lose / push under a seeded RNG, bands, cap, herald gate +# --------------------------------------------------------------------------- + + +def _at_inn(game: Game, name: str) -> object: + """Join *name* and seat them at the inn (MENU surface).""" + game.join(name) + player = game.players[name] + player.mode = Mode.MENU + player.at_location = "inn" + return player + + +def test_gamble_win_under_seeded_rng(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + player = _at_inn(game, "Gambler") + player.gold = 100 + # Seed 2 makes the gamble child roll 11 (you) vs 9 (house) -> a win. + game.rng = GameRNG(seed=2) + out = game.action("Gambler", "gamble", "", "", "", 10) + assert player.gold == 110 # stake doubled back + assert "win" in out.lower() + # No turn spent; one game counted. + assert player.turns_left == game.world.settings.daily_turns + assert player.gambles == 1 + + +def test_gamble_lose_under_seeded_rng(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + player = _at_inn(game, "Gambler") + player.gold = 100 + # Seed 0 rolls 4 (you) vs 9 (house) -> a loss. + game.rng = GameRNG(seed=0) + out = game.action("Gambler", "gamble", "", "", "", 10) + assert player.gold == 90 + assert "lose" in out.lower() + assert player.gambles == 1 + + +def test_gamble_push_under_seeded_rng(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + player = _at_inn(game, "Gambler") + player.gold = 100 + # Seed 1 rolls 6 vs 6 -> a push: no gold change, but it still counts. + game.rng = GameRNG(seed=1) + out = game.action("Gambler", "gamble", "", "", "", 10) + assert player.gold == 100 + assert "push" in out.lower() + assert player.gambles == 1 # a push still consumes a daily game + + +def test_gamble_bet_band_refused(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + player = _at_inn(game, "Gambler") + player.gold = 100_000 + max_bet = game.world.settings.gamble_max_bet + low = game.action("Gambler", "gamble", "", "", "", 0) + assert f"1 to {max_bet}" in low + high = game.action("Gambler", "gamble", "", "", "", max_bet + 1) + assert f"1 to {max_bet}" in high + # A rejected bet neither moves gold nor counts toward the cap. + assert player.gold == 100_000 + assert player.gambles == 0 + + +def test_gamble_unaffordable_refused(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + player = _at_inn(game, "Gambler") + player.gold = 5 + out = game.action("Gambler", "gamble", "", "", "", 10) # within band, can't cover + assert "can't cover" in out.lower() + assert player.gold == 5 + assert player.gambles == 0 + + +def test_gamble_daily_cap_refused(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + player = _at_inn(game, "Gambler") + player.gold = 100_000 + cap = game.world.settings.gamble_daily_cap + player.gambles = cap # already at the cap + out = game.action("Gambler", "gamble", "", "", "", 5) + assert "enough for one day" in out + assert player.gambles == cap # not incremented past the cap + + +def test_gamble_outside_inn_refused(tmp_path: Path, clock: object) -> None: + """The dice live at the inn: the verb is illegal in another building.""" + game = _game(tmp_path, clock) + player = _at_inn(game, "Gambler") + player.at_location = "shop" # the shop has no 'gamble' action + player.gold = 100 + out = game.action("Gambler", "gamble", "", "", "", 10) + assert "can't 'gamble' here" in out.lower() + assert player.gold == 100 + + +def test_gamble_big_win_heralds(tmp_path: Path, clock: object) -> None: + """A win of >= 25 gold reaches the public Herald; a small one does not.""" + game = _game(tmp_path, clock) + player = _at_inn(game, "Gambler") + player.gold = 1000 + + # A 50-gold win (>= the 25 threshold) writes a public dice line. + game.rng = GameRNG(seed=2) # a winning roll + events_before = len(game.events) + game.action("Gambler", "gamble", "", "", "", 50) + new = game.events[events_before:] + assert any(e.kind == "gamble" and e.target == "" for e in new) + assert player.gold == 1050 + + +def test_gamble_small_win_is_quiet(tmp_path: Path, clock: object) -> None: + game = _game(tmp_path, clock) + player = _at_inn(game, "Gambler") + player.gold = 1000 + # A 10-gold win is below the 25-gold Herald threshold: no public line. + game.rng = GameRNG(seed=2) + events_before = len(game.events) + game.action("Gambler", "gamble", "", "", "", 10) + new = game.events[events_before:] + assert all(e.kind != "gamble" for e in new) + assert player.gold == 1010 diff --git a/examples/door-game/tests/test_turns.py b/examples/door-game/tests/test_turns.py index 57a583b2..f227ba5a 100644 --- a/examples/door-game/tests/test_turns.py +++ b/examples/door-game/tests/test_turns.py @@ -37,7 +37,16 @@ def test_ensure_day_resets_on_new_day() -> None: def test_ensure_day_noop_within_same_day() -> None: day = utc(2026, 6, 12).toordinal() - player = make_player(turns_left=4, turn_day=day, bestow_spent=10, bestow_day=day) + # Every day marker is already today, so no allowance (turns, bestow, posts, + # dice) is touched — the rollover is a pure no-op. + player = make_player( + turns_left=4, + turn_day=day, + bestow_spent=10, + bestow_day=day, + post_day=day, + gamble_day=day, + ) reset = ensure_day(player, fixed_clock(utc(2026, 6, 12, 23, 0)), daily_turns=10) assert reset is False assert player.turns_left == 4 @@ -66,3 +75,20 @@ def test_bestow_pool_resets_on_the_same_boundary() -> None: ensure_day(player, fixed_clock(utc(2026, 6, 13, 0, 1)), daily_turns=10) assert player.bestow_spent == 0 assert player.bestow_day == utc(2026, 6, 13).toordinal() + + +def test_social_caps_reset_on_the_same_boundary() -> None: + """Posts and dice counts ride the same UTC rollover as turns and bestow.""" + yesterday = utc(2026, 6, 12).toordinal() + player = make_player( + posts_sent=5, + post_day=yesterday, + gambles=5, + gamble_day=yesterday, + ) + reset = ensure_day(player, fixed_clock(utc(2026, 6, 13, 0, 1)), daily_turns=10) + assert reset is True + assert player.posts_sent == 0 + assert player.post_day == utc(2026, 6, 13).toordinal() + assert player.gambles == 0 + assert player.gamble_day == utc(2026, 6, 13).toordinal() diff --git a/examples/door-game/tests/test_world_loader.py b/examples/door-game/tests/test_world_loader.py index 59282dd2..388c3fb7 100644 --- a/examples/door-game/tests/test_world_loader.py +++ b/examples/door-game/tests/test_world_loader.py @@ -324,6 +324,69 @@ def test_wyrm_min_level_out_of_band_rejected(tmp_path: Path) -> None: load_world(pack) +# --------------------------------------------------------------------------- +# v0.5 social settings: ambush / post / gamble economy bands +# --------------------------------------------------------------------------- + + +def test_ambush_gold_pct_out_of_band_rejected(tmp_path: Path) -> None: + """The steal percentage is a 0..100 band; 101 is rejected by name.""" + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + data["settings"]["ambush_gold_pct"] = 101 # band is 0..100 + + _rewrite(pack / "world.json", mutate) + with pytest.raises(WorldLoadError, match="ambush_gold_pct"): + load_world(pack) + + +def test_ambush_level_band_out_of_band_rejected(tmp_path: Path) -> None: + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + data["settings"]["ambush_level_band"] = 11 # band is 0..10 + + _rewrite(pack / "world.json", mutate) + with pytest.raises(WorldLoadError, match="ambush_level_band"): + load_world(pack) + + +def test_gamble_max_bet_out_of_band_rejected(tmp_path: Path) -> None: + """A max bet of 0 is below the 1..10000 floor: the house needs a real stake.""" + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + data["settings"]["gamble_max_bet"] = 0 # band is 1..10000 + + _rewrite(pack / "world.json", mutate) + with pytest.raises(WorldLoadError, match="gamble_max_bet"): + load_world(pack) + + +def test_post_daily_cap_out_of_band_rejected(tmp_path: Path) -> None: + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + data["settings"]["post_daily_cap"] = 51 # band is 0..50 + + _rewrite(pack / "world.json", mutate) + with pytest.raises(WorldLoadError, match="post_daily_cap"): + load_world(pack) + + +def test_missing_social_setting_rejected(tmp_path: Path) -> None: + """A pack that predates the social settings fails loudly (no silent default).""" + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + del data["settings"]["ambush_min_level"] + + _rewrite(pack / "world.json", mutate) + with pytest.raises(WorldLoadError, match="ambush_min_level"): + load_world(pack) + + # --------------------------------------------------------------------------- # v0.4 loader hardening: glyphs, map size, count caps, and name lengths # diff --git a/examples/door-game/understone/__init__.py b/examples/door-game/understone/__init__.py index dfca5bdf..e435c75d 100644 --- a/examples/door-game/understone/__init__.py +++ b/examples/door-game/understone/__init__.py @@ -1,3 +1,3 @@ """Understone — a BBS-style ANSI door game served over MCP.""" -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/examples/door-game/understone/engine/log.py b/examples/door-game/understone/engine/log.py index 7155866e..420ead71 100644 --- a/examples/door-game/understone/engine/log.py +++ b/examples/door-game/understone/engine/log.py @@ -3,6 +3,11 @@ Events are append-only and ordered by insertion. Each player tracks a cursor (the id of the last event they have seen); ``since`` returns the slice after a cursor and the new cursor to persist. + +An event carries a ``target``: empty means PUBLIC (the broadsheet and the +lobby TV), a player name means a PRIVATE note that only that player reads in +their own catch-up. Targeted rows ride the same id order as public ones, so +the cursor advances identically whether or not a private note was shown. """ from __future__ import annotations @@ -12,13 +17,18 @@ from dataclasses import dataclass @dataclass(frozen=True, slots=True) class Event: - """A single logged happening in the shared world.""" + """A single logged happening in the shared world. + + ``target`` is the empty string for public events (heralded to everyone) + or a player's name for a private note delivered only to that player. + """ event_id: int ts: str kind: str actor: str text: str + target: str = "" def since(events: list[Event], cursor: int) -> tuple[list[Event], int]: @@ -34,3 +44,20 @@ def since(events: list[Event], cursor: int) -> tuple[list[Event], int]: if not fresh: return [], cursor return fresh, fresh[-1].event_id + + +def since_visible(events: list[Event], cursor: int, viewer: str) -> tuple[list[Event], int]: + """Like :func:`since`, but hide private notes not addressed to *viewer*. + + Returns the events newer than ``cursor`` that *viewer* may read — every + public event (empty ``target``) plus the private notes addressed to them — + and the new cursor. The cursor advances to the highest id PAST the old + cursor regardless of visibility, so a private note for someone else is + consumed (never re-scanned) without ever being shown here. + """ + fresh = [e for e in events if e.event_id > cursor] + if not fresh: + return [], cursor + new_cursor = fresh[-1].event_id + visible = [e for e in fresh if not e.target or e.target == viewer] + return visible, new_cursor diff --git a/examples/door-game/understone/engine/models.py b/examples/door-game/understone/engine/models.py index 2df8583d..cbe68461 100644 --- a/examples/door-game/understone/engine/models.py +++ b/examples/door-game/understone/engine/models.py @@ -57,6 +57,10 @@ class Player: bestow_spent: int bestow_day: int wins: int = 0 + posts_sent: int = 0 + post_day: int = 0 + gambles: int = 0 + gamble_day: int = 0 @dataclass(frozen=True, slots=True) @@ -173,3 +177,9 @@ class Settings: dungeon_tiers: tuple[int, ...] boss_monster: str wyrm_min_level: int + ambush_min_level: int + ambush_level_band: int + ambush_gold_pct: int + post_daily_cap: int + gamble_max_bet: int + gamble_daily_cap: int diff --git a/examples/door-game/understone/engine/turns.py b/examples/door-game/understone/engine/turns.py index 85c79a35..73a7da95 100644 --- a/examples/door-game/understone/engine/turns.py +++ b/examples/door-game/understone/engine/turns.py @@ -2,8 +2,9 @@ Turns refresh lazily: the first action on a new UTC day resets the budget rather than relying on a scheduled job. The same rollover resets -the per-player bestow pool, so both daily allowances share one boundary. -The clock is injected so tests can cross midnight deterministically. +the per-player bestow pool and the social daily caps (posts left, dice +played), so every daily allowance shares one boundary. The clock is +injected so tests can cross midnight deterministically. """ from __future__ import annotations @@ -25,9 +26,11 @@ def _utc_ordinal(clock: Callable[[], datetime]) -> int: def ensure_day(player: Player, clock: Callable[[], datetime], daily_turns: int) -> bool: """Refresh daily allowances if the UTC day has advanced. - Returns ``True`` when a reset occurred. Resets both the turn budget - (to *daily_turns*) and the bestow pool (to empty), stamping the current - UTC ordinal onto both day markers. + Returns ``True`` when a reset occurred. On a new UTC day this resets the + turn budget (to *daily_turns*), the bestow pool, the daily post count, and + the daily dice count — each back to its baseline — stamping the current UTC + ordinal onto every day marker. Each counter is reset independently so a + stale stamp on one never suppresses the refresh of another. """ today = _utc_ordinal(clock) reset = False @@ -39,6 +42,14 @@ def ensure_day(player: Player, clock: Callable[[], datetime], daily_turns: int) player.bestow_spent = 0 player.bestow_day = today reset = True + if player.post_day != today: + player.posts_sent = 0 + player.post_day = today + reset = True + if player.gamble_day != today: + player.gambles = 0 + player.gamble_day = today + reset = True return reset diff --git a/examples/door-game/understone/game.py b/examples/door-game/understone/game.py index c6140707..fb1baa6c 100644 --- a/examples/door-game/understone/game.py +++ b/examples/door-game/understone/game.py @@ -16,8 +16,8 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING from understone.engine import combat, leveling, movement, turns -from understone.engine.log import Event, since -from understone.engine.models import Mode, Player, Slot +from understone.engine.log import Event, since_visible +from understone.engine.models import Mode, Monster, Player, Slot from understone.engine.rank import HallEntry, RankEntry, leaderboard from understone.engine.rng import GameRNG from understone.persistence import EVENT_TAIL_KEEP @@ -30,17 +30,24 @@ from understone.screen.viewport import compute_window if TYPE_CHECKING: from collections.abc import Callable - from understone.engine.models import Item, LocationDef, Monster + from understone.engine.models import Item, LocationDef from understone.engine.world import World from understone.persistence import Store VIEW_W = 48 VIEW_H = 16 +# One feed write: ``(kind, actor, text, target)``. An empty target is a PUBLIC +# Herald beat; a player name is a PRIVATE note only that player reads. +EventSpec = tuple[str, str, str, str] + # Free-text length ceilings for player-authored input (the sanitizer chokepoint). _NAME_MAX_LEN = 24 _REASON_MAX_LEN = 120 +# A dice win at or above this many gold is loud enough to reach the Herald. +_GAMBLE_HERALD_MIN = 25 + _COLOR_BY_NAME = {c.value: c for c in Color} # The Understone Herald — the public feed's masthead and its "all quiet" line. @@ -53,6 +60,7 @@ _HERALD_QUIET = "The Vale is still; the Herald has no fresh word for you." # errands stay private. Player names are sanitised at join and monster names come # from the validated pack, so interpolation here is safe. _HERALD_TEMPLATES: dict[str, tuple[str, ...]] = { + # --- PUBLIC (via _herald): everyone reads these on the broadsheet --- "join": ( "{name} has signed the ledger and set out into the Vale.", "A new adventurer, {name}, arrives at the western gate.", @@ -85,6 +93,28 @@ _HERALD_TEMPLATES: dict[str, tuple[str, ...]] = { "{name} fled the Wyrm Below, alive but unproven.", "{name} broke from the Wyrm Below and ran for the light.", ), + # Ambush — the asynchronous player-kill. These win/shame/flee beats crow on + # the public feed; the victim's own private alert is the "ambushed" kind below. + "ambush": ( + "{name} fell upon {target} as they slept — and made off with {steal} gold!", + "Under cover of dawn {name} robbed the sleeping {target} of {steal} gold!", + "{target} slept too long; {name} crept in and lifted {steal} gold!", + ), + "ambush_shame": ( + "{target} woke blade-in-hand; {name} fled bleeding.", + "{name} misjudged the sleeper: {target} woke and sent them running.", + ), + "ambush_flee": ( + "{name} crept up on {target} but lost their nerve and slipped away.", + "{name} thought better of robbing {target} and melted into the dark.", + ), + "gamble": ( + "{name} took the house for {amount} gold at dice!", + "The dice ran hot for {name} — {amount} gold off the house!", + ), + # --- PRIVATE (via _mail): only the named target reads these --- + "ambushed": ("While you slept: {name} ambushed you — {steal} gold stolen.",), + "post": ("{name} left word for {target}: {text}",), } @@ -162,29 +192,65 @@ class Game: if turns.ensure_day(player, self.clock, self.world.settings.daily_turns): player.last_seen = self._now_iso() - def _herald(self, kind: str, actor: str, **fields: object) -> tuple[str, str, str]: - """Build a public feed event for *kind*, picking a phrasing via the RNG. + def _spent_for_today(self, player: Player) -> str: + """Persist *player* and render the shared overworld 'out of turns' frame. - Returns the ``(kind, actor, text)`` tuple ``_persist`` expects. The - phrasing is chosen from :data:`_HERALD_TEMPLATES` so the broadsheet - varies; the choice is deterministic under a seeded RNG. + The turn-exhausted refusal for the overworld verbs (fight and ambush): + the day-roll has already run and may have mutated state, so commit the + player before reporting, then surface the spent line on the map. + """ + self.store.upsert_player(player) + self.store.commit() + return self._overworld_frame( + player, + lines=["You're spent for today. Rest at the inn and return tomorrow."], + ) + + def _herald(self, kind: str, actor: str, **fields: object) -> EventSpec: + """Build a PUBLIC feed event for *kind*, picking a phrasing via the RNG. + + Returns the ``(kind, actor, text, target)`` tuple ``_persist`` expects, + with an empty target (everyone sees it). The phrasing is chosen from + :data:`_HERALD_TEMPLATES` so the broadsheet varies; the choice is + deterministic under a seeded RNG. """ phrasings = _HERALD_TEMPLATES[kind] text = phrasings[self.rng.choice_index(len(phrasings))].format(name=actor, **fields) - return (kind, actor, text) + return (kind, actor, text, "") - def _persist(self, player: Player, *events: tuple[str, str, str]) -> None: + def _mail(self, kind: str, actor: str, target: str, **fields: object) -> EventSpec: + """Build a PRIVATE note for *kind*, delivered only to *target*. + + Same phrasing machinery as :meth:`_herald`, but the returned tuple + carries *target* so only that player reads it in their own catch-up; + it never reaches the public broadsheet or the lobby TV. The recipient + name is exposed to the template as ``{target}`` (templates that don't + reference it simply ignore it), so callers needn't repeat it. + """ + phrasings = _HERALD_TEMPLATES[kind] + chosen = phrasings[self.rng.choice_index(len(phrasings))] + text = chosen.format(name=actor, target=target, **fields) + return (kind, actor, text, target) + + def _persist(self, player: Player, *events: EventSpec, also: Player | None = None) -> None: """Update + append in one transaction, then commit (state changes). - Each event tuple is ``(kind, actor, text)``. New events are appended - to the in-memory feed with the id the store assigns. + Each event tuple is ``(kind, actor, text, target)``. New events are + appended to the in-memory feed with the id the store assigns. When + *also* is given (e.g. an ambush victim), that second player's row is + upserted in the SAME transaction, so both fighters and their events + commit atomically. """ player.last_seen = self._now_iso() self.store.upsert_player(player) + if also is not None: + self.store.upsert_player(also) ts = self._now_iso() - for kind, actor, text in events: - event_id = self.store.insert_event(ts, actor, kind, text) - self.events.append(Event(event_id=event_id, ts=ts, kind=kind, actor=actor, text=text)) + for kind, actor, text, target in events: + event_id = self.store.insert_event(ts, actor, kind, text, target) + self.events.append( + Event(event_id=event_id, ts=ts, kind=kind, actor=actor, text=text, target=target) + ) # Full history lives in SQLite; keep only the recent tail resident. if len(self.events) > EVENT_TAIL_KEEP: del self.events[:-EVENT_TAIL_KEEP] @@ -295,6 +361,10 @@ class Game: bestow_spent=0, bestow_day=today, wins=0, + posts_sent=0, + post_day=today, + gambles=0, + gamble_day=today, ) self.players[clean] = player self._persist(player, self._herald("join", clean)) @@ -421,23 +491,41 @@ class Game: # -- tool: action ---------------------------------------------------- - def action(self, name: str, action: str, target: str, item: str) -> str: - """Dispatch a context verb against the player's current surface.""" + def action( + self, + name: str, + action: str, + target: str, + item: str, + text: str = "", + amount: int = 0, + ) -> str: + """Dispatch a context verb against the player's current surface. + + Most verbs are surface-bound (fight/flee on the overworld, rest/buy/ + gamble inside a building). ``post`` (leave a note for another player) + is the exception: it works anywhere and costs no turn, so it is handled + before the surface branch. + """ player = self._get(name) if player is None: return self._unknown(name) verb = action.strip().lower() + if verb == "post": + return self._post(player, target, text) if player.mode is Mode.TILE: - return self._tile_action(player, verb) - return self._menu_action(player, verb, item) + return self._tile_action(player, verb, target) + return self._menu_action(player, verb, item, amount) - # -- tile-context actions (fight / flee) ----------------------------- + # -- tile-context actions (fight / flee / ambush) -------------------- - def _tile_action(self, player: Player, verb: str) -> str: + def _tile_action(self, player: Player, verb: str, target: str) -> str: if verb in {"fight", "flee"}: return self._resolve_encounter(player, verb) - legal = "fight, flee, or move on with door_move" + if verb == "ambush": + return self._ambush(player, target) + legal = "fight, flee, ambush a sleeping rival, or move on with door_move" return self._overworld_frame( player, lines=[f"There's nothing to '{verb}' out here. You can {legal}."] ) @@ -460,12 +548,7 @@ class Game: ) if not turns.spend_turn(player): - self.store.upsert_player(player) - self.store.commit() - return self._overworld_frame( - player, - lines=["You're spent for today. Rest at the inn and return tomorrow."], - ) + return self._spent_for_today(player) child = self.rng.child() if verb == "flee": @@ -476,7 +559,7 @@ class Game: def _apply_xp_with_herald( self, player: Player, amount: int, lines: list[str] - ) -> list[tuple[str, str, str]]: + ) -> list[EventSpec]: """Award XP, append per-level narration to *lines*, and herald the climb. Returns the public-feed events to persist: a single ``level_up`` beat at @@ -510,7 +593,7 @@ class Game: def _apply_fight(self, player: Player, result: combat.FightResult) -> str: lines = list(result.log) - events: list[tuple[str, str, str]] = [] + events: list[EventSpec] = [] player.hp = max(1, player.hp + result.hp_delta) if result.outcome is combat.Outcome.WIN: @@ -526,9 +609,124 @@ class Game: self._persist(player, *events) return self._overworld_frame(player, lines=lines) + # -- tile-context action: ambush (asynchronous PvP) ------------------ + + def _ambush(self, attacker: Player, target_name: str) -> str: + """Fall upon a sleeping rival to rob them — the classic door-game player-kill beat. + + Legal on the overworld only. Eligibility is checked in a fixed order, + each with its own in-fiction refusal: the target must exist, not be + yourself, both of you must be seasoned (the gatekeeper shields the + young), you must be within the level band, and — the SLEEP RULE — the + target must not yet have begun their own day (anyone who has acted today + is awake and un-ambushable). Then the day rolls and a turn is spent, + exactly as a fight. The attempt is spent (recorded) on every outcome. + """ + refusal = self._ambush_refusal(attacker, target_name) + if refusal is not None: + return self._overworld_frame(attacker, lines=[refusal]) + target = self.players[target_name.strip()] + + self._ensure_day(attacker) + if not turns.spend_turn(attacker): + return self._spent_for_today(attacker) + + return self._resolve_ambush(attacker, target) + + def _ambush_refusal(self, attacker: Player, target_name: str) -> str | None: + """Return the in-fiction refusal for an illegal ambush, or ``None``. + + Each branch maps to a distinct rule, checked in order so the message + names the first thing wrong. The order matters: existence before + identity, level gates before the band, and the sleep/once-a-day rules + last (they are the live-play defences). + """ + target = self._get(target_name) + if target is None: + return self._unknown(target_name) + if target.name == attacker.name: + return "You can hardly ambush yourself, traveller." + settings = self.world.settings + floor = settings.ambush_min_level + if attacker.level < floor or target.level < floor: + return f"The gatekeeper shields the young: ambush is barred below level {floor}." + if abs(attacker.level - target.level) > settings.ambush_level_band: + return ( + f"{target.name} is too far from your measure to make a fair mark " + f"(within {settings.ambush_level_band} levels only)." + ) + if target.turn_day >= self._today_ordinal(): + return ( + f"{target.name} is already abroad and watchful today — you cannot " + "catch them sleeping." + ) + if target.hp <= 1: + # Mercy rule: a freshly-robbed sleeper sits at 1 HP. Pile-on bandits + # don't get to kick someone already in the ditch (this is checked + # after the sleep rule, so an awake 1-HP rival reports as watchful). + return ( + f"{target.name} already lies battered in the ditch — even bandits have standards." + ) + if self.store.has_ambushed(attacker.name, target.name, self._today_ordinal()): + return f"You have already lain in wait for {target.name} today." + return None + + def _today_ordinal(self) -> int: + """Return the attacker-current UTC ordinal (the sleep-rule boundary).""" + return self.clock().toordinal() + + def _resolve_ambush(self, attacker: Player, target: Player) -> str: + """Resolve a committed ambush and persist both fighters atomically.""" + settings = self.world.settings + sleeper = Monster( + tier=0, + name=target.name, + hp=target.hp, + atk=target.atk, + def_=target.def_, + xp=0, + gold=0, + ) + result = combat.resolve_fight(self.rng.child(), attacker, sleeper) + lines = list(result.log) + events: list[EventSpec] = [] + day = self._today_ordinal() + + if result.outcome is combat.Outcome.WIN: + # The sleeper still trades blows before falling; bank the attacker's + # wear so the narrated counter-strikes match the sheet (mirrors + # _apply_fight). A win never drops the attacker below 1 HP. + attacker.hp = max(1, attacker.hp + result.hp_delta) + steal = max(0, target.gold * settings.ambush_gold_pct // 100) + attacker.gold += steal + target.gold -= steal + target.hp = 1 + target.x, target.y = self.world.spawn + target.mode = Mode.TILE + target.at_location = "" + lines.append(f"You rob {target.name} of {steal} gold and melt away.") + events.append(self._herald("ambush", attacker.name, target=target.name, steal=steal)) + events.append(self._mail("ambushed", attacker.name, target.name, steal=steal)) + elif result.bounce_to_spawn: + attacker.hp = 1 + attacker.x, attacker.y = self.world.spawn + lines.append(f"{target.name} wakes blade-in-hand and you flee bleeding.") + events.append(self._herald("ambush_shame", attacker.name, target=target.name)) + else: + # A grinding stalemate: no gold moves, but the attacker keeps any + # wear taken before breaking off (the fight/wyrm-flee convention). + attacker.hp = max(1, attacker.hp + result.hp_delta) + lines.append(f"Your nerve fails and you slip away from {target.name}.") + events.append(self._herald("ambush_flee", attacker.name, target=target.name)) + + # The attempt is spent on every outcome; record it inside the txn. + self.store.record_ambush(attacker.name, target.name, day) + self._persist(attacker, *events, also=target) + return self._overworld_frame(attacker, lines=lines) + # -- menu-context actions -------------------------------------------- - def _menu_action(self, player: Player, verb: str, item: str) -> str: + def _menu_action(self, player: Player, verb: str, item: str, amount: int) -> str: loc = self.world.location_by_key(player.at_location) if loc is None: player.mode = Mode.TILE @@ -554,6 +752,8 @@ class Game: return self._descend(player) if verb == "challenge": return self._challenge(player) + if verb == "gamble": + return self._gamble(player, amount) return self._location_menu(player, lines=[f"The '{verb}' option isn't ready."]) def _leave(self, player: Player) -> str: @@ -674,6 +874,99 @@ class Game: return f"(+{item.def_} DEF)" return f"(+{item.heal} HP)" + # -- the inn mailbox: leave word for another player ------------------ + + def _surface(self, player: Player, *, lines: list[str]) -> str: + """Render *lines* on the player's current surface (map or menu). + + Used by ``post``, which is legal in either mode, so its reply must + match whichever surface the player is standing on. + """ + if player.mode is Mode.MENU: + return self._location_menu(player, lines=lines) + return self._overworld_frame(player, lines=lines) + + def _post(self, player: Player, target_name: str, text: str) -> str: + """Leave a private note for another player (legal anywhere; no turn). + + The note is delivered as a PRIVATE event the recipient alone reads in + their next ``door_log`` ("While you were away"); the sender gets an + in-fiction confirmation. The body runs through the same sanitizer as + every other player-authored string, and a small daily cap keeps the + hearth from becoming a billboard. + """ + target = self._get(target_name) + if target is None: + return self._surface(player, lines=[self._unknown(target_name)]) + if target.name == player.name: + return self._surface(player, lines=["You need no note to talk to yourself."]) + clean = self._sanitize(text, _REASON_MAX_LEN) + if clean is None: + return self._surface( + player, + lines=["The innkeep can't make out that scrawl. Plain words, briefly put."], + ) + + self._ensure_day(player) + cap = self.world.settings.post_daily_cap + if player.posts_sent >= cap: + return self._surface( + player, + lines=[f"You've left all the word you may today ({cap} notes). Try tomorrow."], + ) + player.posts_sent += 1 + note = self._mail("post", player.name, target.name, text=clean) + self._persist(player, note) + return self._surface(player, lines=["The innkeep tucks the note above the hearth."]) + + # -- the inn dice game: wager against the house --------------------- + + def _gamble(self, player: Player, amount: int) -> str: + """Wager *amount* gold on a single 2d6 roll against the house. + + Inn only (the menu gates the verb). Player and house each roll two + dice; higher total wins the stake, a tie pushes (no gold moves) and a + loss forfeits it. A daily count cap limits how many times the cup comes + out; a push still counts. Costs no turn. A notable win is heralded. + """ + max_bet = self.world.settings.gamble_max_bet + if amount < 1 or amount > max_bet: + return self._location_menu( + player, + lines=[f"The house takes wagers of 1 to {max_bet} gold. Name your stake."], + ) + if player.gold < amount: + return self._location_menu( + player, + lines=[f"You can't cover a {amount}-gold wager (you hold {player.gold})."], + ) + + self._ensure_day(player) + cap = self.world.settings.gamble_daily_cap + if player.gambles >= cap: + return self._location_menu( + player, + lines=[f"The innkeep waves you off — {cap} games is enough for one day."], + ) + player.gambles += 1 + + child = self.rng.child() + you = child.randint(1, 6) + child.randint(1, 6) + house = child.randint(1, 6) + child.randint(1, 6) + events: list[EventSpec] = [] + if you > house: + player.gold += amount + line = f"You roll {you}, the house {house}. You win {amount} gold!" + if amount >= _GAMBLE_HERALD_MIN: + events.append(self._herald("gamble", player.name, amount=amount)) + elif you < house: + player.gold -= amount + line = f"You roll {you}, the house {house}. You lose {amount} gold." + else: + line = f"You roll {you}, the house {house}. A push — your stake stands." + self._persist(player, *events) + return self._location_menu(player, lines=[line]) + def _descend(self, player: Player) -> str: """Run the dungeon gauntlet: one foe per configured tier, back to back.""" self._ensure_day(player) @@ -683,7 +976,7 @@ class Game: lines=["You're too weary to descend today. Return tomorrow."], ) lines = ["You descend into the Understone Deep..."] - events: list[tuple[str, str, str]] = [] + events: list[EventSpec] = [] for tier in self.world.settings.dungeon_tiers: band = self.world.monsters_for_tier_band(tier, tier) if not band: @@ -834,23 +1127,59 @@ class Game: # -- tool: log ------------------------------------------------------- + def _backfill_durable_mail(self, player: Player, visible: list[Event]) -> list[Event]: + """Merge any private notes that predate the resident tail into *visible*. + + Public history older than the in-memory tail is gone by design (the + broadsheet does not keep), but mail is durable: a note left while the + recipient was away must still surface however many public events have + since evicted it from the resident tail. When the player's cursor lies + before the oldest resident event, we pull their targeted rows from + SQLite for that gap and splice them in by id, deduped against the rows + already shown. The cursor still advances to the highest resident id. + """ + oldest_resident = self.events[0].event_id if self.events else 0 + # The resident tail already covers everything from oldest_resident on; + # a backfill is only needed when the cursor predates that boundary. + if player.log_cursor >= oldest_resident - 1: + return visible + durable = self.store.targeted_events_since(player.name, player.log_cursor) + seen = {event.event_id for event in visible} + merged = visible + [event for event in durable if event.event_id not in seen] + merged.sort(key=lambda event: event.event_id) + return merged + def log(self, name: str) -> str: """Report events since the player's cursor, then advance it. - Dressed as the Understone Herald broadsheet: a masthead, the fresh - dispatches, then the status footer. Read-path/cursor semantics are - unchanged — only the framing is new. + Dressed as the Understone Herald broadsheet: a masthead, the public + dispatches, any PRIVATE notes left for this player ("While you were + away"), then the status footer. Public and private rows ride one id + order, so the cursor advances identically whether or not a private note + appeared — a note meant for someone else is consumed, never re-shown, + and never leaks here. """ player = self._get(name) if player is None: return self._unknown(name) - fresh, new_cursor = since(self.events, player.log_cursor) - if not fresh: + visible, new_cursor = since_visible(self.events, player.log_cursor, player.name) + visible = self._backfill_durable_mail(player, visible) + # Advance past every fresh row (visible or not) so private notes for + # others are consumed once; persist the new cursor. + if new_cursor != player.log_cursor: + player.log_cursor = new_cursor + self._persist(player) + if not visible: return f"{_HERALD_HEADER}\n{_HERALD_QUIET}\n" + self._footer(player) - lines = [_HERALD_HEADER, "Word from across the Vale since your last visit:"] - lines.extend(f" - {event.text}" for event in fresh) - player.log_cursor = new_cursor - self._persist(player) + public = [e for e in visible if not e.target] + private = [e for e in visible if e.target == player.name] + lines = [_HERALD_HEADER] + if public: + lines.append("Word from across the Vale since your last visit:") + lines.extend(f" - {event.text}" for event in public) + if private: + lines.append("While you were away, word was left for you:") + lines.extend(f" - {event.text}" for event in private) return "\n".join(lines) + "\n" + self._footer(player) # -- tool: rank ------------------------------------------------------ diff --git a/examples/door-game/understone/persistence.py b/examples/door-game/understone/persistence.py index 9b82f450..434dc369 100644 --- a/examples/door-game/understone/persistence.py +++ b/examples/door-game/understone/persistence.py @@ -28,6 +28,8 @@ from understone.engine.rank import HallEntry, RankEntry if TYPE_CHECKING: from pathlib import Path +# Pre-1.0 the schema mutates in place and the stamp is not yet meaningful; +# version discipline (and migrations) begins at 1.0. _SCHEMA_VERSION = 1 # How many of the newest events to hydrate at construction. Full history stays @@ -59,6 +61,10 @@ _PLAYER_COLUMNS = ( "bestow_spent", "bestow_day", "wins", + "posts_sent", + "post_day", + "gambles", + "gamble_day", ) @@ -99,15 +105,27 @@ class Store: log_cursor INTEGER NOT NULL, bestow_spent INTEGER NOT NULL, bestow_day INTEGER NOT NULL, - wins INTEGER NOT NULL DEFAULT 0 + wins INTEGER NOT NULL DEFAULT 0, + posts_sent INTEGER NOT NULL DEFAULT 0, + post_day INTEGER NOT NULL DEFAULT 0, + gambles INTEGER NOT NULL DEFAULT 0, + gamble_day INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL, - actor TEXT NOT NULL, - kind TEXT NOT NULL, - text TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + actor TEXT NOT NULL, + kind TEXT NOT NULL, + text TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS ambushes ( + attacker TEXT NOT NULL, + target TEXT NOT NULL, + day INTEGER NOT NULL, + PRIMARY KEY (attacker, target, day) ); CREATE TABLE IF NOT EXISTS hall_of_fame ( @@ -174,11 +192,15 @@ class Store: _player_to_row(player), ) - def insert_event(self, ts: str, actor: str, kind: str, text: str) -> int: - """Append an event row (no commit) and return its new id.""" + def insert_event(self, ts: str, actor: str, kind: str, text: str, target: str = "") -> int: + """Append an event row (no commit) and return its new id. + + ``target`` is empty for a public event or a player name for a private + note that only that player reads in their own catch-up. + """ cur = self._conn.execute( - "INSERT INTO events(ts, actor, kind, text) VALUES(?, ?, ?, ?)", - (ts, actor, kind, text), + "INSERT INTO events(ts, actor, kind, text, target) VALUES(?, ?, ?, ?, ?)", + (ts, actor, kind, text, target), ) return int(cur.lastrowid or 0) @@ -190,6 +212,26 @@ class Store: ) return int(cur.lastrowid or 0) + def record_ambush(self, attacker: str, target: str, day: int) -> None: + """Mark that *attacker* has spent their ambush on *target* for *day*. + + Idempotent: the ``(attacker, target, day)`` primary key means a repeat + write is ignored, so re-recording the same attempt is harmless. No + commit — the façade folds this into the per-action transaction. + """ + self._conn.execute( + "INSERT OR IGNORE INTO ambushes(attacker, target, day) VALUES(?, ?, ?)", + (attacker, target, day), + ) + + def has_ambushed(self, attacker: str, target: str, day: int) -> bool: + """Return whether *attacker* already ambushed *target* on *day*.""" + row = self._conn.execute( + "SELECT 1 FROM ambushes WHERE attacker=? AND target=? AND day=?", + (attacker, target, day), + ).fetchone() + return row is not None + def commit(self) -> None: """Commit the current transaction.""" self._conn.commit() @@ -231,6 +273,21 @@ class Store: for row in rows ] + def targeted_events_since(self, viewer: str, cursor: int) -> list[Event]: + """Return *viewer*'s private notes past *cursor*, ascending by id. + + Public history older than the resident tail is ephemeral by design (the + broadsheet does not keep), but private mail is durable: a note left while + the recipient was away must survive however many public events have since + pushed it out of the in-memory tail. The façade pulls the recipient's + targeted rows from SQLite to backfill that gap before rendering. + """ + rows = self._conn.execute( + "SELECT * FROM events WHERE target=? AND id>? ORDER BY id", + (viewer, cursor), + ).fetchall() + return [_row_to_event(row) for row in rows] + def journal_mode(self) -> str: """Return the active journal mode (for diagnostics / tests).""" row = self._conn.execute("PRAGMA journal_mode").fetchone() @@ -265,6 +322,10 @@ def _player_to_row(player: Player) -> tuple[object, ...]: player.bestow_spent, player.bestow_day, player.wins, + player.posts_sent, + player.post_day, + player.gambles, + player.gamble_day, ) @@ -292,6 +353,10 @@ def _row_to_player(row: sqlite3.Row) -> Player: bestow_spent=row["bestow_spent"], bestow_day=row["bestow_day"], wins=row["wins"], + posts_sent=row["posts_sent"], + post_day=row["post_day"], + gambles=row["gambles"], + gamble_day=row["gamble_day"], ) @@ -302,4 +367,5 @@ def _row_to_event(row: sqlite3.Row) -> Event: kind=row["kind"], actor=row["actor"], text=row["text"], + target=row["target"], ) diff --git a/examples/door-game/understone/server.py b/examples/door-game/understone/server.py index b1b135d3..01cad10d 100644 --- a/examples/door-game/understone/server.py +++ b/examples/door-game/understone/server.py @@ -138,6 +138,30 @@ BESTOWING FORTUNE (use sparingly) Treat it as seasoning, not a salt-shaker: reserve it for the rare, earned beat, and never promise a reward you cannot actually deliver within the cap. +THE SOCIAL LAYER (rivals, mail, and dice) + Understone is a SHARED world, and three verbs let players touch one another. + * AMBUSH (door_action action="ambush" target=, on the overworld). + A classic-door-game-style player-kill: you fall upon a RIVAL WHO HAS NOT YET ACTED + TODAY and rob them. The SLEEP RULE is the heart of it — a player who has + already taken their turn that day is awake and cannot be ambushed, so the + surest defence is simply to play. The gatekeeper shields the young (both of + you must clear a level floor) and only matches near-equals (a level band). + On a win you take a slice of their gold and they wake at the spawn at 1 HP; + a public Herald crows the deed and the victim gets a PRIVATE note. But the + sleeper may WAKE: lose, and YOU are the one who flees bleeding, shamed on + the feed and gaining nothing. You get one attempt per rival per day, win or + lose. Narrate ambush as a real betrayal — and losing one as just deserts. + * POST (door_action action="post" target= text=, anywhere). + Leave a private note at the inn for another player; they read it on their + next door_log under "While you were away". It costs no turn, is capped per + day, and the note is PRIVATE — it never reaches the public Herald or the + lobby TV. Good for taunts after an ambush, alliances, or a kind word. + * GAMBLE (door_action action="gamble" amount=, at the inn). + Wager gold on a single throw of 2d6 against the house: roll higher to + double your stake, tie to push, roll lower to lose it. It costs no turn but + is capped per day. A big win is heralded; a quiet one is just a story you + tell. Remind players the house has no mercy and the odds are even at best. + TOOL CHEAT-SHEET door_help This manual. door_join(player) Sign in (creates or resumes a character). @@ -145,8 +169,10 @@ TOOL CHEAT-SHEET door_look(player) Redraw the current view (map or menu). door_move(player, ...) Walk the overworld (free). steps="NNEE" or heading="east" + distance=3 (max 8 per call). - door_action(player, action) Context verb: fight, flee, rest, buy, sell, - heal, descend, challenge (the Wyrm), leave. + door_action(player, action) Context verb: fight, flee, ambush (a rival), + rest, buy, sell, heal, gamble (dice at the + inn), descend, challenge (the Wyrm), post (a + note for another player), leave. door_log(player) Read the Understone Herald (the shared feed). door_rank(player) The leaderboard + Hall of Legends (★ = wins). door_bestow(player, reason...) Grant a little gold/healing for a story beat. @@ -304,31 +330,45 @@ def door_move(player: str, steps: str = "", heading: str = "", distance: int = 1 @mcp.tool() -def door_action(player: str, action: str, target: str = "", item: str = "") -> str: +def door_action( + player: str, + action: str, + target: str = "", + item: str = "", + text: str = "", + amount: int = 0, +) -> str: """Take a context-sensitive action in the world. The legal verbs depend on where the adventurer is. On the overworld: - 'fight' or 'flee' a wandering monster (fighting spends one daily turn). - Inside a building: 'rest' (inn), 'buy'/'sell' (shop), 'heal' (healer), - or 'leave'. At the dungeon: 'descend' the gauntlet, or 'challenge' the + 'fight' or 'flee' a wandering monster (fighting spends one daily turn), or + 'ambush' a named rival who has not yet acted today — a sleeping-rival + robbery in the spirit of the classic door-game player-kill (target=, spends a turn). + Inside a building: 'rest' (inn), 'buy'/'sell' (shop), 'heal' (healer), or + 'leave'. At the inn you may also 'gamble' a stake of gold at dice + (amount=). At the dungeon: 'descend' the gauntlet, or 'challenge' the Wyrm Below — the endgame boss and the only way to win. The challenge is gated by level (under-level heroes are turned away in-fiction) and, once allowed, spends a daily turn and resolves in a single call like a fight: a victory frees the Vale and begins a new life (see door_help), a defeat - bounces you home. An illegal verb returns the verbs valid right here. + bounces you home. Anywhere, 'post' leaves a private note for another player + (target=, text=) that they read on their next door_log; + posting costs no turn. An illegal verb returns the verbs valid right here. Args: player: The adventurer's name. - action: The verb to attempt (fight, flee, rest, buy, sell, heal, - descend, challenge, leave). - target: Reserved for future targeted actions. + action: The verb to attempt (fight, flee, ambush, rest, buy, sell, + heal, gamble, descend, challenge, post, leave). + target: The other player's name for 'ambush' and 'post'. item: For shop 'buy', the item id to purchase. + text: For 'post', the note left for the target (<= 120 characters). + amount: For inn 'gamble', the gold wagered on the dice. """ blank = _guard_name(player) if blank is not None: return blank try: - return _game().action(player, action, target, item) + return _game().action(player, action, target, item, text, amount) except Exception: log.exception("door_action failed for %r action=%r", player, action) return _unexpected() diff --git a/examples/door-game/understone/watch.py b/examples/door-game/understone/watch.py index 6c63ce9c..7ae25d47 100644 --- a/examples/door-game/understone/watch.py +++ b/examples/door-game/understone/watch.py @@ -114,13 +114,16 @@ def build_state_payload(game: Game) -> dict[str, object]: def _recent_events(game: Game) -> list[Event]: - """Return the last :data:`_HERALD_LIMIT` resident events, oldest-first. + """Return the last :data:`_HERALD_LIMIT` resident PUBLIC events, oldest-first. - The façade keeps events in ascending id order, so the list tail IS the - newest window — a plain slice is correct even when event ids are sparse - (AUTOINCREMENT gaps must not shrink the feed). + PRIVATE notes (a non-empty ``target`` — ambush victim alerts, inn mail) + are filtered out first: the lobby TV is a public broadsheet and must never + show a message addressed to one player. The façade keeps events in + ascending id order, so the tail of the public slice IS the newest public + window — correct even when AUTOINCREMENT ids are sparse. """ - return game.events[-_HERALD_LIMIT:] + public = [event for event in game.events if not event.target] + return public[-_HERALD_LIMIT:] # The Watch page. One self-contained document: inline CSS + vanilla JS, no diff --git a/examples/door-game/understone/world/data/locations.json b/examples/door-game/understone/world/data/locations.json index 8efb325b..fb930c19 100644 --- a/examples/door-game/understone/world/data/locations.json +++ b/examples/door-game/understone/world/data/locations.json @@ -4,11 +4,12 @@ "name": "The Sleeping Drake", "glyph": "I", "color": "town", - "actions": ["rest", "leave"], + "actions": ["rest", "gamble", "leave"], "flavor": [ "Lamplight pools on worn oak tables.", "The innkeeper nods toward the hearth.", - "A night's rest restores you fully." + "A night's rest restores you fully.", + "In the corner, a dice cup waits for a wager." ] }, "shop": { diff --git a/examples/door-game/understone/world/data/world.json b/examples/door-game/understone/world/data/world.json index cad6e806..ee4e9ea5 100644 --- a/examples/door-game/understone/world/data/world.json +++ b/examples/door-game/understone/world/data/world.json @@ -116,6 +116,12 @@ "bestow_daily_budget": 25, "dungeon_tiers": [4, 5], "boss_monster": "wyrm_below", - "wyrm_min_level": 6 + "wyrm_min_level": 6, + "ambush_min_level": 3, + "ambush_level_band": 2, + "ambush_gold_pct": 25, + "post_daily_cap": 5, + "gamble_max_bet": 50, + "gamble_daily_cap": 5 } } diff --git a/examples/door-game/understone/world/loader.py b/examples/door-game/understone/world/loader.py index aa73902c..befaab06 100644 --- a/examples/door-game/understone/world/loader.py +++ b/examples/door-game/understone/world/loader.py @@ -47,6 +47,12 @@ SETTINGS_BANDS: dict[str, tuple[int, int | None]] = { "xp_base": (1, None), "bestow_daily_budget": (0, 500), "wyrm_min_level": (1, 50), + "ambush_min_level": (1, 50), + "ambush_level_band": (0, 10), + "ambush_gold_pct": (0, 100), + "post_daily_cap": (0, 50), + "gamble_max_bet": (1, 10000), + "gamble_daily_cap": (0, 100), } # Per-kind amount bands for the overworld event table (inclusive). @@ -602,6 +608,12 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons dungeon_tiers=dungeon_tiers, boss_monster=boss_monster, wyrm_min_level=values["wyrm_min_level"], + ambush_min_level=values["ambush_min_level"], + ambush_level_band=values["ambush_level_band"], + ambush_gold_pct=values["ambush_gold_pct"], + post_daily_cap=values["post_daily_cap"], + gamble_max_bet=values["gamble_max_bet"], + gamble_daily_cap=values["gamble_daily_cap"], )