Skip to content

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

Closed

Description

@mmacy

Summary

Add an authorial ad-hoc roll to the command union so a referee front end can resolve a chance outcome by rolling through the seeded session — logged, replayable, and grounded in a typed event — instead of inventing a number or rolling out-of-band. Concretely: a RollDice command (arbitrary dice expression), and likely companion Check (ability check) and Save (forced saving throw) commands, each drawing from a named RNG stream and emitting a result event.

Motivation

An LLM-driven referee is a first-class consumer (docs/front-ends/llm-referees.md). B/X is freeform, so a referee constantly adjudicates things the content model can't express (a puzzle, a bluff, a bespoke hazard, "does the frayed rope hold?"). The authorial/referee commands (SetFlag, SpawnMonsters, GrantItem, SetDoorState, PlaceParty, AdvanceTime, …) let it commit facts, but there is no way to make the engine roll dice on demand.

That gap forces a front end into one of two bad options for any chance outcome:

  • Invent the number — ungrounded, unauditable, fudgeable, and it violates "the engine owns the dice."
  • Roll out-of-band (a side dice tool) — the result lives outside the seeded streams, so it can't be held fixed when the same seed is replayed against a changed prompt or a different model. That defeats the determinism-as-eval story (llm-referees.md, "Determinism is the eval story").

The right answer is a roll that is an accepted, logged command drawing from a named seeded stream and emitting a typed event — identical in kind to how SpawnMonsters already rolls its count through the seeded RNG and logs itself. This request completes the authorial surface for freeform adjudication.

(Requested by the osrlib-referee project — an MCP front end that holds a live GameSession and keeps the LLM on narration while the engine owns all mechanics/state.)

Current state

The rolling primitives already exist, but only as core functions taking an RngStream — none is reachable as a command:

  • osrlib.core.dice.roll(expression, stream) -> RollResult
  • osrlib.core.abilities.ability_check(score, stream, modifier=0) -> AbilityCheckResult
  • osrlib.core.combat.saving_throw(...), check_morale(...), roll_reaction(...)

The command union (osrlib.crawl.commands.AnyCommand, discriminated on command_type, ALL_COMMAND_CLASSES) is entirely specific keyed actions (search, pick_lock, force_door, cast_spell, resolve_battle_round, …). There is no generic roll/check/save command.

Proposed API

Additive command(s) in osrlib.crawl.commands, registered in ALL_COMMAND_CLASSES so they join AnyCommand. Exact naming/splitting is the implementer's call; the contract matters more than the shape. A reasonable set:

  • RollDice — the core need. Field: expression: str (a dice-grammar expression parsed by osrlib.core.dice). Rolls via dice.roll, emits a result event carrying the expression, the total, and the individual die results.
  • Check — an ability check. Fields: score: int (3–18) and modifier: int = 0. Wraps ability_check.
  • Save — a forced saving throw. Fields to target a party member and pick a SaveCategory (+ any element/magical flags saving_throw needs). Wraps saving_throw.

Starting with RollDice alone is acceptable if Check/Save want their own follow-up; RollDice unblocks the front end.

Design constraints (honor the engine's contracts)

  • Authorial / all modes. Legal in every SessionMode, like SetFlag (inherits _ALL_MODES). Model these on the existing referee-command exemplars (SetFlag, SpawnMonsters) and their handlers.
  • Accepted, logged, RNG-consuming. The roll is an accepted command that draws from the RNG and is appended to the command log (so it is part of the reproducible trajectory). Validation must be a pure pre-phase: a malformed dice expression (or out-of-range score) is a Rejection returned before any draw, so a rejected roll consumes no RNG and is not logged — the standard rejection discipline.
  • Named stream — recommend a dedicated one. Draw from a stream such as "adjudication" (new named stream) rather than reusing a mechanical stream like "combat"/"encounter". Rationale: ad-hoc referee rolls must not perturb the draw sequence of keyed mechanics, or they'd shift combat/encounter outcomes and break replay stability of keyed content. Confirm against the existing named-stream conventions (core/rng.py, RngStreams).
  • Typed result event. Emit an event (e.g. DiceRolledEvent) with a stable message code + typed fields (expression, total, individual dice; for Check/Save, the raw d20 and success/failure), following the event-emission contract. Add a default message template so format_message renders a line (mirror saving_throw_rolled, events.py). Visibility: referee (these are the referee's hidden rolls; a player-facing wire strips them).
  • Determinism + schema. Additive only — respect schema_version additivity and the frozen public-API rules. len(TypeAdapter(AnyCommand).json_schema()["oneOf"]) == len(ALL_COMMAND_CLASSES) must still hold (count goes up); regenerate any shipped schema artifacts (docs/reference/commands/commands.json, events.json) and message-code reference. Same seed + same command ⇒ identical event, byte for byte.

References in this repo

  • Command exemplars + union: src/osrlib/crawl/commands.py (SetFlag, SpawnMonsters, ALL_COMMAND_CLASSES, AnyCommand, parse_command, CommandResult).
  • Referee-command handlers: src/osrlib/crawl/session.py (e.g. _handle_set_flag ~L796; the mode gate + accepted-command logging path ~L330–355).
  • Roll primitives: src/osrlib/core/dice.py (roll), src/osrlib/core/abilities.py (ability_check), src/osrlib/core/combat.py (saving_throw, check_morale).
  • Event contract + an event exemplar: src/osrlib/core/events.py (base Event, Visibility, saving_throw_rolled ~L249); messages: src/osrlib/messages.py (format_message).
  • Rejection: src/osrlib/core/validation.py (Rejection{code, params}).
  • Determinism + the eval rationale this serves: docs/front-ends/llm-referees.md ("The authorial surface", "Determinism is the eval story"); the determinism invariant in AGENTS.md.

Acceptance criteria

  • RollDice (and, if in scope, Check/Save) exist in commands.py, registered in ALL_COMMAND_CLASSES and reachable via AnyCommand / parse_command.
  • Legal in all modes; executing one returns accepted=True with a typed result event; a malformed expression / out-of-range input returns accepted=False with a Rejection and consumes no RNG and no log entry.
  • Draws from a dedicated named stream; a mechanical stream's draw sequence is provably unaffected by an interleaved adjudication roll (test).
  • Deterministic + replayable: same seed + same command ⇒ identical event; the roll appears in the accepted-command log and a replay reproduces it.
  • format_message renders a default line for the new event(s); message-code + schema reference artifacts regenerated; AnyCommand.json_schema() oneOf count assertion still passes.
  • Tests cover accept/reject paths, determinism, replay, stream isolation, and schema generation. ruff format --check, ruff check, pytest green.

Out of scope / notes

  • No front-end code here — this is purely the engine command surface. The osrlib-referee MCP server consumes it.
  • Follow this repo's plan → rubber-duck → PR discipline (AGENTS.md): a short design note first is worthwhile, since stream choice and the Check/Save shapes have judgment calls.

Activity

  1. added
    enhancementNew feature or request
    acceptedTriaged and approved for implementation
    on Jul 5, 2026
  2. self-assigned this
    on Jul 5, 2026
  3. mmacy commented on Jul 5, 2026

    @mmacy
    OwnerAuthor

    Reviewed and accepted — implementation-ready plan

    I ran a verification pass against main at 68102f6 (every claim below was checked in the code, not from memory) and pinned the design calls an implementer would otherwise have to guess at. Together with the issue body, this comment is the ducked plan — follow the AGENTS.md plan → rubber-duck → PR loop. Most judgment calls are pinned here with rationale; one is deliberately left as a call to make in the code (the validation surface, decision 2) — it's flagged with both options, their trade-offs, and a recommended default.

    Verdict on the request

    The gap is real and the proposal fits the codebase almost 1:1 — it's purely additive, determinism-safe, and rides the existing referee-command contract. Green-lit. One scoping change and two corrections to the issue's framing follow.

    Verification summary (all confirmed unless noted)

    • Primitives exist, none reachable as a command. dice.roll(expression, stream) -> RollResult (src/osrlib/core/dice.py:121); RollResult keeps rolls: tuple[int, ...] and total: int (dice.py:75-87); dice.parse raises ContentValidationError on a malformed expression before any draw (dice.py:111). ability_check(score, stream, modifier=0) (abilities.py:267) — raises ValueError if score not in 3–18 (abilities.py:286). saving_throw(...) (combat.py:1661).
    • Command union is purely specific keyed actions — AnyCommand (commands.py:1662), ALL_COMMAND_CLASSES 44-tuple (commands.py:1614-1659); no generic roll/check/save.
    • Exemplars confirm the pattern. SpawnMonsters (commands.py:1450) really does roll through the seeded RNG in its handler — count = roll(command.count_dice, session.streams.get(ENCOUNTER_STREAM)).total (session.py:818) — and logs itself via the accepted-command path (session.py:340-355, append at :346). SetFlag handler at session.py:796.
    • Referee visibility already exists — Visibility.REFEREE (core/events.py:79-87), used by morale/reaction/effect events. SavingThrowRolledEvent is the event exemplar (core/events.py:237-255).
    • New named stream is additive and safe. Streams are string-keyed and SHA-256-forked from the master seed; "adding draws to one subsystem's stream never shifts results in another" (core/rng.py:15-18, .get at rng.py:253-266). No "adjudication" stream exists today.
    • No prior work — nothing named RollDice/adjudication/DiceRolled anywhere; no branch or open PR. Fully greenfield.

    Correction 1 — scope the first PR to RollDice only

    Save is not a thin wrapper. combat.saving_throw (combat.py:1661) is entity-coupled: it needs a live combatant with .saves, equipped-item bonuses, and alignment scoping, and it already emits its own SavingThrowRolledEvent. An "authorial save against an arbitrary target number" would need a new lower-level primitive or a synthetic target — a real design decision, not a wrapper. Check is closer but ability_check raises on out-of-range score, so it needs the same validation-surface decision as below. Ship RollDice alone first (it unblocks the osrlib-referee front end); spin Check and Save into their own follow-up issues, each with a short design note.

    Correction 2 — the oneOf "assertion" is not a real test; the real gates are stricter

    The len(TypeAdapter(AnyCommand).json_schema()["oneOf"]) == len(ALL_COMMAND_CLASSES) line only appears as prose in docs/front-ends/llm-referees.md — there is no such test. commands.json/events.json are build products generated by tools/docs/gen_schema_reference.py, not committed source. The gates that will actually fail your PR are listed under "Gates you must keep green" below.

    Pinned design decisions — RollDice

    1. Command — in src/osrlib/crawl/commands.py: class RollDice(Command) with command_type: Literal["roll_dice"] = "roll_dice" and one field expression: str. Leave allowed_modes at the default (_ALL_MODES, commands.py:106/123) so it's legal in all five modes. Frozen + extra="ignore" are enforced by the base __pydantic_init_subclass__ (commands.py:125-132). Add to ALL_COMMAND_CLASSES and __all__.

    2. Validation surface — a call to make in the code (weigh it carefully). How should a malformed expression be rejected? Two coherent options; decide in-code, document the choice, and have the rubber-duck sign off:

      • Option A — pydantic @field_validator("expression") calling dice.parse (matches SpawnMonsters). A bad expression raises ContentValidationError at construction, so parse_command() rejects it and execute() never runs — the same way every malformed field value is handled today (SpawnMonsters.count_dice at commands.py:1482-1487; a bad MoveParty.direction). RollDice then carries Rejections: None (like SetFlag), adds no rejection code, and touches no rejection_codes.json. Cost: does not satisfy the issue's literal AC ("returns accepted=False with a Rejection") — the failure surfaces as a parse-time exception the front end catches, not a CommandResult.
      • Option B — handler-level Rejection. Keep expression: str unconstrained; the handler parse()es inside try/except ContentValidationError and returns ([Rejection(code="session.command.invalid_dice_expression", params={"expression": ...})], []) before any draw. Satisfies the AC literally and rides the one CommandResult envelope the front end already switches on. Cost: introduces a structural rejection where every existing session.command.* rejection is a state/legality rejection (wrong_mode, unknown_monster, not_in_dungeon), and makes RollDice validate its dice string differently from SpawnMonsters — the "special case on shared infrastructure" the rubber-duck is told to hunt (AGENTS.md). Adds the rejection code + a rejection_codes.json description.

      Recommended default: Option A. In a project this consistency-minded, matching SpawnMonsters and keeping Rejection reserved for state/legality is the lower-surface, easier-to-defend choice — and because RollDice is legal in all modes with no state preconditions, "given a well-formed expression it never rejects" is simply true, so Rejections: None is honest. The AC's "returns a Rejection" was likely written before noticing SpawnMonsters already sets the pydantic precedent for dice strings; treat the convention as superseding the AC wording, consciously and in the PR notes. Whichever option you take, the RNG draw must happen strictly after validation so a malformed expression consumes no RNG and no log entry. If you choose B, state in the PR why you diverged from SpawnMonsters.

    3. Stream — add ADJUDICATION_STREAM = "adjudication" alongside the other crawl stream constants in session.py (near ENCOUNTER_STREAM, session.py:108), export it in that module's __all__, and draw via session.streams.get(ADJUDICATION_STREAM). A dedicated stream is the whole point: ad-hoc referee rolls must not perturb the draw sequence of "encounter"/"combat" etc., or they'd shift keyed outcomes and break replay stability.

    4. Event — DiceRolledEvent mirroring SavingThrowRolledEvent (core/events.py:237-255): event_type: Literal["dice_rolled"], visibility: Visibility = Visibility.REFEREE (these are the referee's hidden rolls), allowed_codes = {"adjudication.dice_rolled"}, and typed fields expression: str, total: int, rolls: tuple[int, ...] (populate from RollResult.rolls/.total). Register it in src/osrlib/crawl/events.py so it joins ALL_EVENT_CLASSES/AnyEvent (crawl/events.py:708/712) — or the session log can't round-trip it.

    5. Message template — add an adjudication.dice_rolled entry to _TEMPLATES in src/osrlib/messages.py, mirroring the combat.save.* templates' interpolation style. This is mandatory (see gate below), not optional.

    6. Handler — _handle_roll_dice(session, command) -> tuple[list[Rejection], list[Event]] in session.py, registered in _REFEREE_HANDLERS (session.py:954-965) — an unregistered command class makes execute raise ValueError (session.py:341-342). Body: validate per decision 2 (Option A: the field validator already guarantees the string parses, so the handler has no rejection branch; Option B: try/except → Rejection here), then result = roll(command.expression, session.streams.get(ADJUDICATION_STREAM)) and return ([], [DiceRolledEvent(code="adjudication.dice_rolled", expression=command.expression, total=result.total, rolls=result.rolls)]).

    Exact touch points

    File Change
    src/osrlib/crawl/commands.py RollDice class (+ @field_validator on expression if decision 2 → Option A); add to ALL_COMMAND_CLASSES + __all__; docstring with Modes:/Rejections:/Events: sections
    src/osrlib/crawl/session.py ADJUDICATION_STREAM constant (+ __all__); _handle_roll_dice; register in _REFEREE_HANDLERS
    src/osrlib/core/events.py DiceRolledEvent (mirror SavingThrowRolledEvent)
    src/osrlib/crawl/events.py register DiceRolledEvent in the event class list feeding ALL_EVENT_CLASSES
    src/osrlib/messages.py _TEMPLATES["adjudication.dice_rolled"]
    docs/reference/rng-streams.md document ADJUDICATION_STREAM (gated)
    tools/docs/rejection_codes.json description for session.command.invalid_dice_expression — decision 2 → Option B only
    tests/test_commands.py add "RollDice": dict(expression="2d6") to the samples dict (:34)

    Gates you must keep green (these will bite)

    • tests/test_docs_gates.py — the RollDice docstring must carry Modes:/Rejections:/Events: sections (:140); the Modes: list must exactly equal allowed_modes, i.e. all five backticked modes town, exploring, encounter, battle, game_over (:149); every rejection code named in the docstring must be found in source by scan_rejection_codes() (:161) and have a description in tools/docs/rejection_codes.json with none stale (:191) — relevant only if decision 2 → Option B (Option A documents Rejections: None); named event classes must be registered (:175); and every *_STREAM constant in a module __all__ must be named in docs/reference/rng-streams.md (:201).
    • tests/test_events_kernel.py:216 — {code for cls in ALL_EVENT_CLASSES for code in cls.allowed_codes} == set(_TEMPLATES), a bidirectional exact match. Your new adjudication.dice_rolled code must have exactly one _TEMPLATES entry and no stray codes. And :220 — formatting a sample of the event must never return the bare code, so the template must actually interpolate. Confirm sample_event/all_shipped_events can construct DiceRolledEvent from its fields.
    • tests/test_commands.py:82 — the parametrized round-trip builds every class from the samples dict; a missing entry errors the test.

    Test matrix (map each to an acceptance criterion)

    • Accept path — execute(RollDice(expression="2d6")) returns accepted=True with one DiceRolledEvent; the command appears in command_log.
    • Reject path (malformed expression) — Option A: RollDice(expression="garbage") raises ContentValidationError at construction (assert via pytest.raises), so nothing reaches the RNG or log. Option B: execute returns accepted=False with Rejection(code="session.command.invalid_dice_expression"), no event, no command_log entry. Either way, assert the "adjudication" stream state is unchanged (no draw consumed).
    • Stream isolation — snapshot the "encounter" (or "combat") stream, execute an interleaved RollDice, then a keyed mechanic; assert the keyed stream's draw sequence is byte-identical to a run without the roll. This is the criterion that proves the dedicated-stream design.
    • Determinism / replay — same seed + same RollDice ⇒ identical event byte-for-byte; the roll survives a save/restore + replay of the command log.
    • Visibility — DiceRolledEvent.model_fields["visibility"].default is Visibility.REFEREE (mirror test_effect_bookkeeping_is_referee_visibility).
    • Legal in all modes — executes with accepted=True from town, exploring, encounter, battle, game_over.

    Definition of done

    All the above green, plus ruff format --check, ruff check, pytest clean, and the mkdocs schema/reference build regenerating cleanly (CI fails on diff). Per AGENTS.md: open a short design note, rubber-duck it, then the impl PR on a phase-style branch.

    Follow-ups (separate issues)

    • Check — wraps ability_check; same validation-surface decision as decision 2 for the score (out-of-range), same "adjudication" stream, its own event/code + template.
    • Save — needs a design decision first: a new target-number saving-throw primitive vs. a synthetic target, because combat.saving_throw is entity-coupled. Do not treat as a wrapper.
  4. added a commit that references this issue on Jul 5, 2026
  5. added a commit that references this issue on Jul 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

acceptedTriaged and approved for implementationenhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions