mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Understone — a BBS-style ANSI door game served over MCP."""
|
||||
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 ------------------------------------------------------
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user