feat(examples): Understone v0.5 — ambushes, the inn mailbox, and dice

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.
This commit is contained in:
Patrick Buckley
2026-06-12 18:45:53 -07:00
parent d40c4c85ee
commit 08d46f086f
20 changed files with 1538 additions and 81 deletions
+9 -1
View File
@@ -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. |
+1 -1
View File
@@ -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"
+16
View File
@@ -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]
+47
View File
@@ -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()
+1 -1
View File
@@ -6,4 +6,4 @@ import understone
def test_version_present() -> None:
assert understone.__version__ == "0.4.0"
assert understone.__version__ == "0.5.0"
@@ -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)
+741
View File
@@ -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
+27 -1
View File
@@ -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()
@@ -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
#
+1 -1
View File
@@ -1,3 +1,3 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.4.0"
__version__ = "0.5.0"
+28 -1
View File
@@ -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
@@ -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
+16 -5
View File
@@ -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
+370 -41
View File
@@ -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 ------------------------------------------------------
+76 -10
View File
@@ -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"],
)
+51 -11
View File
@@ -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=<player>, 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=<player> text=<message>, 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=<gold>, 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=<name>, 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=<gold>). 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=<name>, text=<message>) 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()
+8 -5
View File
@@ -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
@@ -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": {
@@ -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
}
}
@@ -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"],
)