Skip to content

Add RollDice authorial adjudication command + release 1.1.0 (#22) - #23

Merged
mmacy merged 2 commits into
mainfrom
issue-22-impl
Jul 5, 2026
Merged

mmacy merged 2 commits into
mainfrom
issue-22-impl

Conversation

@mmacy

@mmacy mmacy commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Closes #22.

Adds an authorial RollDice command so a referee front end (the osrlib-referee MCP server) can resolve a freeform chance outcome by rolling through the seeded session — accepted, logged, replayable, and grounded in a typed event — instead of inventing a number or rolling out-of-band. This completes the authorial surface for freeform adjudication described in docs/front-ends/llm-referees.md.

What's in scope

RollDice only. Per the plan's Correction 1, Check and Save are deferred to their own follow-up issues (Save is not a thin wrapper — combat.saving_throw is entity-coupled). RollDice alone unblocks the front end.

Design

  • Command — RollDice(expression: str) in crawl/commands.py, appended to ALL_COMMAND_CLASSES (stable, additive wire order) and reachable via AnyCommand / parse_command. Legal in every mode (inherits _ALL_MODES).
  • Validation surface — Option A (the plan's recommended default). A malformed expression is rejected at construction by a @field_validator calling dice.parse, exactly as SpawnMonsters.count_dice is. This keeps Rejection reserved for state/legality and matches the existing dice-string precedent. This deliberately supersedes the issue's literal acceptance-criterion wording ("returns accepted=False with a Rejection"): a bad expression surfaces as a parse-time ContentValidationError the front end catches, not a CommandResult. The RNG draw happens strictly after validation, so a malformed roll consumes no draw and no log entry — the same rejection purity, achieved one layer earlier.
  • Dedicated stream — a new ADJUDICATION_STREAM = "adjudication" keeps ad-hoc referee rolls off the mechanical streams, so an adjudication roll never perturbs the draw sequence of a keyed mechanic (encounter/combat/exploration) and keyed outcomes stay fixed under replay.
  • Event — DiceRolledEvent (referee visibility) carries expression, total, and the individual rolls, with a stable adjudication.dice_rolled message code and a default format_message template.

Deliberate deviation from the plan's touch-point table

The plan's table listed defining DiceRolledEvent in core/events.py but registering it in crawl/events.py's CRAWL_EVENT_CLASSES. I instead define and register it entirely in crawl/events.py. Every other CRAWL_EVENT_CLASSES member emitted by a referee handler (FlagSetEvent, MonstersSpawnedEvent, TimeAdvancedEvent, GameOverEvent) is defined in crawl/events.py; DiceRolledEvent is emitted only by the crawl session handler _handle_roll_dice, so a split home (define in core, register in the crawl list) would make it the lone crawl-registered event living in core — a "one home per symbol" violation (AGENTS.md greenfield discipline). No osrlib.core function needs to emit it, so core placement buys nothing and the layering rule is upheld either way.

Tests

New coverage maps to each acceptance criterion: accept + logged (TestRollDice), malformed-expression rejection with no draw / no log, referee visibility, byte-identical determinism under a fixed seed, stream isolation (both a "touches only the adjudication stream" diff test and an interleaved-roll-leaves-SpawnMonsters-unshifted test), save/load + command-log replay (test_persistence.py), and legality in every mode. Fixture wiring added to test_commands.py, test_events_kernel.py, and the property-based command fuzzer (test_crawl_properties.py). Docs (rng-streams.md) and CHANGELOG.md updated.

ruff format --check, ruff check, pytest (1512 passed, 58 skipped), and a strict mkdocs build are all clean.

Review provenance

Followed the AGENTS.md plan → rubber-duck → PR loop; the issue body + owner comment are the ducked plan. A fresh skeptical reviewer audited the implementation against AGENTS.md invariants (determinism, layering, command purity, events/schema discipline, greenfield anti-patterns) and the acceptance criteria. Verdict: SOLID, no blocking findings. It endorsed the event-home deviation as the more consistent choice, confirmed the stream-isolation tests are non-tautological, and verified the message template renders honestly when total != sum(rolls). Its two non-blocking notes (name the AC-wording supersession explicitly; record the event-home deviation) are folded into this description.

Add a RollDice command so a referee front end can resolve a freeform chance
outcome by rolling through the seeded session — accepted, logged, replayable,
and grounded in a typed event — instead of inventing a number or rolling
out-of-band.

- RollDice(expression) in crawl/commands.py, registered in ALL_COMMAND_CLASSES
  and AnyCommand. A malformed expression is rejected at construction by a
  field validator (matching SpawnMonsters.count_dice), so validation stays a
  pure pre-phase and a bad roll consumes no draw and no log entry.
- A dedicated ADJUDICATION_STREAM keeps ad-hoc referee rolls off the mechanical
  streams, so they never perturb a keyed mechanic's draw sequence.
- DiceRolledEvent (referee visibility) carries the expression, total, and the
  individual die results, with an adjudication.dice_rolled message template.
- Tests cover accept/reject, referee visibility, determinism, stream isolation,
  replay, and legality in every mode; docs and CHANGELOG updated.
Bump the version for the RollDice authorial adjudication command (#22). Minor
release: the change is purely additive to the public command/event surface.

Rename the CHANGELOG [Unreleased] section to [1.1.0] and refresh the compare
links; regenerate uv.lock. The local dry run passed: uv build, check_dist,
and a fresh-venv install_smoke all clean for 1.1.0.

Versioned docs (mike) adoption — the AGENTS.md trigger for this first post-1.0
minor with API changes — is consciously deferred to keep the release focused;
tracked in #24.
@mmacy mmacy changed the title Add RollDice authorial adjudication command (#22) Add RollDice authorial adjudication command + release 1.1.0 (#22) Jul 5, 2026
@mmacy

mmacy commented Jul 5, 2026

Copy link
Copy Markdown
Owner Author

Folded the 1.1.0 release into this PR (following the v1.0.0 precedent of bumping within the impl PR and tagging its merge commit):

  • pyproject.toml → 1.1.0 (minor: additive public command/event surface), uv.lock regenerated.
  • CHANGELOG [Unreleased] → [1.1.0] - 2026-07-05 with refreshed compare links.
  • Local release dry run passed: uv build, tools/release/check_dist.py dist 1.1.0, and a fresh-venv install_smoke.py 1.1.0 all clean.

Versioned-docs (mike) adoption — the AGENTS.md trigger for this first post-1.0 minor with API changes — is consciously deferred to keep the release focused; tracked in #24.

On merge, an annotated v1.1.0 tag on the merge commit fires release.yml (gate → build → dual-OS audit/smoke → PyPI trusted publish → GitHub Release).

@mmacy
mmacy merged commit 30d6c31 into main Jul 5, 2026
5 checks passed
@mmacy
mmacy deleted the issue-22-impl branch July 5, 2026 23:08
mmacy added a commit that referenced this pull request Jul 30, 2026
The light reveal (1.2.1) was deliberately ephemeral: the player view
recomputed what the party sees by its own light on every build, so the
moment the party walked on — or the torch guttered out — every cell it
had glimpsed but never stepped on fell dark again. A front end's automap
could therefore only remember the physically walked footprint, and a
mapped room vanished behind the party (osr-web issue #23). The client
cannot fix this itself; the projection is its only source of geometry.

Persist sight as its own state, distinct from exploration:

- `DungeonState.seen` holds the remembered cells beside `explored`, same
  `"{dungeon}:{level}"` key shape. `mark_seen` is an idempotent bulk
  append, new cells in sorted (x, y) order so serialization is
  deterministic across runs.
- `GameSession.execute` folds the current light reveal into `seen` after
  every accepted command (`_persist_sight`) — the one hook that covers
  every reveal-changing action uniformly: entering, moving, stairs,
  doors, lighting, placement, discovery, battle-flight relocation.
  Rejected commands change no state, so they never reach it.
- The projection unions walked, remembered-seen, and currently lit
  cells; the live reveal stays so lighting a torch reveals immediately,
  even when a light effect lands on the ledger without a command.
- `_light_reveal` and its radius helpers move from views.py to
  exploration.py, beside `_sight_passes` — they are game logic now, not
  projection.

Seen is memory of sight, never exploration: movement cost still reads
the walked footprint (a lit-but-unwalked cell costs the full 30),
drop-pile visibility stays on explored cells, an undiscovered secret
door's sealed alcove is never recorded, and the referee view is
unchanged. The field is additive with a default — no schema bump, no
migration; an older save loads with empty memory and starts remembering.

Adds TestSeenPersistence (walk-away persistence, save/load round-trip,
pre-`seen` save loads clean, sealed-alcove no-leak) and mark_seen
idempotence/ordering coverage; the leak property test's invariant
becomes explored-or-seen, and the cross-level test now expects level 1's
memory to survive the descent while the live reveal still never bleeds
across levels.

Claude-Session: https://claude.ai/code/session_01Gu9KVvucLb7NoquyNjmxxv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add authorial ad-hoc roll commands (RollDice/Check/Save) for freeform adjudication

1 participant