Repository navigation
Add authorial ad-hoc roll commands (RollDice/Check/Save) for freeform adjudication #22
Description
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) -> RollResultosrlib.core.abilities.ability_check(score, stream, modifier=0) -> AbilityCheckResultosrlib.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 byosrlib.core.dice). Rolls viadice.roll, emits a result event carrying the expression, the total, and the individual die results.Check— an ability check. Fields:score: int(3–18) andmodifier: int = 0. Wrapsability_check.Save— a forced saving throw. Fields to target a party member and pick aSaveCategory(+ any element/magical flagssaving_throwneeds). Wrapssaving_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, likeSetFlag(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
Rejectionreturned 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 messagecode+ typed fields (expression, total, individual dice; forCheck/Save, the raw d20 and success/failure), following the event-emission contract. Add a default message template soformat_messagerenders a line (mirrorsaving_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_versionadditivity 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(baseEvent,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 inAGENTS.md.
Acceptance criteria
-
RollDice(and, if in scope,Check/Save) exist incommands.py, registered inALL_COMMAND_CLASSESand reachable viaAnyCommand/parse_command. - Legal in all modes; executing one returns
accepted=Truewith a typed result event; a malformed expression / out-of-range input returnsaccepted=Falsewith aRejectionand 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_messagerenders 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,pytestgreen.
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 theCheck/Saveshapes have judgment calls.
Activity
mmacy commentedon Jul 5, 2026
Reviewed and accepted — implementation-ready plan
I ran a verification pass against
mainat68102f6(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 theAGENTS.mdplan → 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)
dice.roll(expression, stream) -> RollResult(src/osrlib/core/dice.py:121);RollResultkeepsrolls: tuple[int, ...]andtotal: int(dice.py:75-87);dice.parseraisesContentValidationErroron a malformed expression before any draw (dice.py:111).ability_check(score, stream, modifier=0)(abilities.py:267) — raisesValueErrorifscorenot in 3–18 (abilities.py:286).saving_throw(...)(combat.py:1661).AnyCommand(commands.py:1662),ALL_COMMAND_CLASSES44-tuple (commands.py:1614-1659); no generic roll/check/save.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).SetFlaghandler atsession.py:796.Visibility.REFEREE(core/events.py:79-87), used by morale/reaction/effect events.SavingThrowRolledEventis the event exemplar (core/events.py:237-255).core/rng.py:15-18,.getatrng.py:253-266). No"adjudication"stream exists today.RollDice/adjudication/DiceRolledanywhere; no branch or open PR. Fully greenfield.Correction 1 — scope the first PR to
RollDiceonlySaveis 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 ownSavingThrowRolledEvent. 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.Checkis closer butability_checkraises on out-of-range score, so it needs the same validation-surface decision as below. ShipRollDicealone first (it unblocks the osrlib-referee front end); spinCheckandSaveinto 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 stricterThe
len(TypeAdapter(AnyCommand).json_schema()["oneOf"]) == len(ALL_COMMAND_CLASSES)line only appears as prose indocs/front-ends/llm-referees.md— there is no such test.commands.json/events.jsonare build products generated bytools/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 —
RollDiceCommand — in
src/osrlib/crawl/commands.py:class RollDice(Command)withcommand_type: Literal["roll_dice"] = "roll_dice"and one fieldexpression: str. Leaveallowed_modesat 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 toALL_COMMAND_CLASSESand__all__.Validation surface — a call to make in the code (weigh it carefully). How should a malformed
expressionbe rejected? Two coherent options; decide in-code, document the choice, and have the rubber-duck sign off:@field_validator("expression")callingdice.parse(matchesSpawnMonsters). A bad expression raisesContentValidationErrorat construction, soparse_command()rejects it andexecute()never runs — the same way every malformed field value is handled today (SpawnMonsters.count_diceatcommands.py:1482-1487; a badMoveParty.direction).RollDicethen carriesRejections: None(likeSetFlag), adds no rejection code, and touches norejection_codes.json. Cost: does not satisfy the issue's literal AC ("returnsaccepted=Falsewith aRejection") — the failure surfaces as a parse-time exception the front end catches, not aCommandResult.Rejection. Keepexpression: strunconstrained; the handlerparse()es insidetry/except ContentValidationErrorand returns([Rejection(code="session.command.invalid_dice_expression", params={"expression": ...})], [])before any draw. Satisfies the AC literally and rides the oneCommandResultenvelope the front end already switches on. Cost: introduces a structural rejection where every existingsession.command.*rejection is a state/legality rejection (wrong_mode,unknown_monster,not_in_dungeon), and makesRollDicevalidate its dice string differently fromSpawnMonsters— the "special case on shared infrastructure" the rubber-duck is told to hunt (AGENTS.md). Adds the rejection code + arejection_codes.jsondescription.Recommended default: Option A. In a project this consistency-minded, matching
SpawnMonstersand keepingRejectionreserved for state/legality is the lower-surface, easier-to-defend choice — and becauseRollDiceis legal in all modes with no state preconditions, "given a well-formed expression it never rejects" is simply true, soRejections: Noneis honest. The AC's "returns a Rejection" was likely written before noticingSpawnMonstersalready 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 fromSpawnMonsters.Stream — add
ADJUDICATION_STREAM = "adjudication"alongside the other crawl stream constants insession.py(nearENCOUNTER_STREAM,session.py:108), export it in that module's__all__, and draw viasession.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.Event —
DiceRolledEventmirroringSavingThrowRolledEvent(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 fieldsexpression: str,total: int,rolls: tuple[int, ...](populate fromRollResult.rolls/.total). Register it insrc/osrlib/crawl/events.pyso it joinsALL_EVENT_CLASSES/AnyEvent(crawl/events.py:708/712) — or the session log can't round-trip it.Message template — add an
adjudication.dice_rolledentry to_TEMPLATESinsrc/osrlib/messages.py, mirroring thecombat.save.*templates' interpolation style. This is mandatory (see gate below), not optional.Handler —
_handle_roll_dice(session, command) -> tuple[list[Rejection], list[Event]]insession.py, registered in_REFEREE_HANDLERS(session.py:954-965) — an unregistered command class makesexecuteraiseValueError(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→Rejectionhere), thenresult = 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
src/osrlib/crawl/commands.pyRollDiceclass (+@field_validatoronexpressionif decision 2 → Option A); add toALL_COMMAND_CLASSES+__all__; docstring withModes:/Rejections:/Events:sectionssrc/osrlib/crawl/session.pyADJUDICATION_STREAMconstant (+__all__);_handle_roll_dice; register in_REFEREE_HANDLERSsrc/osrlib/core/events.pyDiceRolledEvent(mirrorSavingThrowRolledEvent)src/osrlib/crawl/events.pyDiceRolledEventin the event class list feedingALL_EVENT_CLASSESsrc/osrlib/messages.py_TEMPLATES["adjudication.dice_rolled"]docs/reference/rng-streams.mdADJUDICATION_STREAM(gated)tools/docs/rejection_codes.jsonsession.command.invalid_dice_expression— decision 2 → Option B onlytests/test_commands.py"RollDice": dict(expression="2d6")to thesamplesdict (:34)Gates you must keep green (these will bite)
tests/test_docs_gates.py— theRollDicedocstring must carryModes:/Rejections:/Events:sections (:140); theModes:list must exactly equalallowed_modes, i.e. all five backticked modestown,exploring,encounter,battle,game_over(:149); every rejection code named in the docstring must be found in source byscan_rejection_codes()(:161) and have a description intools/docs/rejection_codes.jsonwith none stale (:191) — relevant only if decision 2 → Option B (Option A documentsRejections: None); named event classes must be registered (:175); and every*_STREAMconstant in a module__all__must be named indocs/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 newadjudication.dice_rolledcode must have exactly one_TEMPLATESentry and no stray codes. And:220— formatting a sample of the event must never return the bare code, so the template must actually interpolate. Confirmsample_event/all_shipped_eventscan constructDiceRolledEventfrom its fields.tests/test_commands.py:82— the parametrized round-trip builds every class from thesamplesdict; a missing entry errors the test.Test matrix (map each to an acceptance criterion)
execute(RollDice(expression="2d6"))returnsaccepted=Truewith oneDiceRolledEvent; the command appears incommand_log.RollDice(expression="garbage")raisesContentValidationErrorat construction (assert viapytest.raises), so nothing reaches the RNG or log. Option B:executereturnsaccepted=FalsewithRejection(code="session.command.invalid_dice_expression"), no event, nocommand_logentry. Either way, assert the"adjudication"stream state is unchanged (no draw consumed)."encounter"(or"combat") stream, execute an interleavedRollDice, 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.RollDice⇒ identical event byte-for-byte; the roll survives a save/restore + replay of the command log.DiceRolledEvent.model_fields["visibility"].default is Visibility.REFEREE(mirrortest_effect_bookkeeping_is_referee_visibility).accepted=Truefromtown,exploring,encounter,battle,game_over.Definition of done
All the above green, plus
ruff format --check,ruff check,pytestclean, and the mkdocs schema/reference build regenerating cleanly (CI fails on diff). PerAGENTS.md: open a short design note, rubber-duck it, then the impl PR on aphase-style branch.Follow-ups (separate issues)
Check— wrapsability_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, becausecombat.saving_throwis entity-coupled. Do not treat as a wrapper.release 1.1.0
Merge pull request #23 from mmacy/issue-22-impl