Adaptations and pinned interpretations
osrlib follows OSE rules-as-written. This page is the single register for the places
where that isn't enough: pinned interpretations, where the SRD text is ambiguous
and the library commits to one reading, and documented adaptations, where the
tabletop game assumes a human referee and the library provides a default behind a
Ruleset flag. Look here instead of grepping docstrings; every entry is also stated
in the owning docstring and locked by a test.
This register doubles as the project's evidence ledger: entries cite the source files and test names that lock them. Those citations refer to the osrlib repository — they are provenance for the curious, not links on this site.
Pinned interpretations
Multi-prime-requisite XP tiers carry no penalties
The SRD applies the standard XP-modifier table "to characters with a single prime
requisite", and the elf's and halfling's class descriptions note only bonuses (+5%,
+10%). Pinned: elf and halfling get exactly their stated bonus tiers and no penalty
tiers — an elf with INT 9 and STR 9 has a 0% modifier, never −10%. Locked by
test_classes.py::TestXpModifier::test_multi_prime_requisite_classes_have_no_penalties.
XP tiers evaluate best-first, first-match-wins
The XP-modifier tiers are one uniform representation for all classes: ordered tiers of
{modifier_pct, minimum scores}, evaluated best-first, first tier whose minimums all
hold wins. The standard table's penalty rows only work under first-match-wins (a prime
requisite of 7 must fall past the +10/+5/none tiers and land on −10%), and the
halfling's "at least 13 in one prime requisite" +5% tier is expressed as two
single-minimum tiers under the same rule. Locked by
test_classes.py::TestXpModifier.
XP percentage results floor
The SRD never says how to round a ±5/10/20% XP modifier. Pinned: the modified award is
floored (integer arithmetic, toward negative infinity). Locked by
test_classes.py::TestApplyXp::test_modifier_floors.
First-level HP re-roll repeats until the die shows 3 or more
The optional rule says a roll of "1 or 2" may be re-rolled but not whether a re-rolled
1–2 is re-rolled again. Pinned: with hp_reroll_at_first_level on, the raw die
(before the CON modifier) is re-rolled while it shows 1–2, each re-roll consuming a
draw. Locked by test_character.py::TestRollHitPoints.
Adjustment reductions are even per lowered score
"For every two points by which an ability score is reduced, one point may be added" —
the SRD doesn't say whether a single score may be lowered by an odd amount as long as
the total is even. Pinned: the two-for-one trade is per score, so each lowered score
drops by an even amount and nothing is stranded. The total raise must equal exactly
the sum of reductions divided by two. Locked by
test_abilities.py::TestAdjustment.
Gear combat facets are exempt from class weapon policies
Torch, holy water, and burning oil appear on both the weapon table and the gear list;
they compile as gear with an embedded combat facet (one entry per physical item, no
item has two ids). Class weapon policies govern the weapons list only: a strict
quality-tag reading would forbid a cleric holy water or a torch, which is absurd. One
uniform rule: any class may buy, hold, and use all three. This deliberately
over-grants relative to a strict reading — a magic-user (RAW: dagger only) may also
throw oil or swing a torch — a consequence chosen, not overlooked. Locked by
test_items.py::TestEquipLegality.
Basic encumbrance tracks treasure weight against the general 1,600-coin cap
The SRD's maximum load rule ("The maximum load any character can carry is 1,600 coins
of weight") sits in the general encumbrance text, and basic encumbrance says "The
weight of treasure carried is tracked to make sure that the character's maximum load
is not exceeded." Pinned: the cap is general, not a detailed-mode extra — under both
tracking modes, tracked weight above 1,600 coins means the character cannot move
(movement 0). Basic mode's "carrying a significant amount of treasure" stays a referee
judgment: a plain carrying_treasure boolean the game sets, no invented threshold.
Locked by test_items.py::TestMovementRates and the encumbrance property tests.
Miscellaneous gear is a flat 80 coins under detailed encumbrance
The SRD gives adventuring gear no per-item weights and says gear "may be counted as 80
coins of weight". Pinned: any miscellaneous gear carried adds a flat 80 coins, once,
regardless of how much gear it is. Items with a listed weight of - (holy water,
burning oil) have no tracked weight of their own — they are gear, covered by the
flat 80. Locked by test_items.py::TestWeights.
Ammunition weighs 0
The ammunition table has no weight column, and the SRD states "The listed weight of
missile weapons already includes the weight of the ammunition and its container."
Pinned: ammunition compiles with weight 0. Locked by
test_srd_data.py::TestEquipmentData.
Sling stones cost 0, lot size 1
The sling stones cost cell reads Free. Pinned: cost 0, purchase lot 1. Locked by
test_srd_data.py::TestEquipmentData.
INT-granted languages come from the Other Languages table and may not duplicate natives
The SRD says high-INT characters "may also choose additional languages from the list
of languages available in the setting" (at the referee's discretion). Pinned: choices
come from the twenty Other Languages, and a choice may not duplicate a class native —
a dwarf cannot spend an INT language on Dwarvish. Locked by
test_character.py::TestExtraLanguages.
The attack matrix is clamp(THAC0 − AC, 2, 20) and extends beyond the printed columns
Every printed matrix cell equals clamp(THAC0 − AC, 2, 20) — locked as a property
over the shipped table — and AC values outside the printed −3..9 columns extend by
the same formula: the printed bounds are page layout, not a rules cliff. The clamping
is exactly what distinguishes matrix mode from the thac0_arithmetic flag once
modifiers push totals past the plateaus. Locked by
test_monster_data.py::TestCombatTables and
test_combat.py::TestAttackRoll::test_matrix_versus_arithmetic_divergence_at_the_plateaus.
Initiative ties re-roll
RAW offers "re-roll or resolve simultaneously"; simultaneous resolution is a
different combat model the engine doesn't attempt. Pinned: tied sides — and tied
individuals among themselves — re-roll in stable input order until distinct, each
re-roll consuming draws. Locked by test_combat.py::TestInitiative.
Slow actors act after all non-slow actors
Pinned reading of "always act last, as if they had lost initiative" for the general
case: slow-weapon actors act after every non-slow actor, ordered among themselves by
their side's initiative (their own results under individual initiative), then stable
order. Locked by test_combat.py::TestInitiative::test_slow_actors_act_last_by_side_initiative.
Helpless defenders are hit automatically in melee, consuming no attack draw
Paralysed and sleeping defenders are hit automatically in melee — no roll is
consumed, damage only, per RAW. A No hit roll required defender (green slime,
yellow mould) is likewise hit without a roll. Locked by
test_combat.py::TestAttackRoll::test_helpless_defender_auto_hit_consumes_no_draw.
Morale "incapacitated" means dead, paralysed, petrified, or asleep
The RAW trigger text says "slain, paralysed, etc". Pinned for the half-the-side
trigger and the sleeping dies-to-a-blade hook. Situational morale adjustments clamp
to ±2 per RAW and never apply to ML 2 (never fights) or ML 12 (never checks); two
passed checks mean no further checks. Locked by test_combat.py::TestMorale.
Energy drain reverses level_up
Per level drained, mirroring level_up exactly in reverse: above name level subtract
the flat-bonus delta (no roll, no CON); otherwise roll the class hit die plus the CON
modifier (minimum 1 per die) and subtract it — rolling the lost die is the
RAW-faithful reading of "loses one Hit Die of hit points" that keeps the model
stateless, with no per-level HP history field. Drain never reduces max or current HP
below 1 while the character retains a level; death by drain happens only by losing
the last level. XP policy is per-monster data: the wight sets XP to the floored
midpoint of the former and new levels' thresholds; wraith, spectre, and vampire set
it to the new level's threshold exactly. Two-level drains apply the procedure twice
and set XP once. On the terminal drain the killing level counts in levels_lost (a
level-1 victim loses 1 level; a spectre draining a level-2 fighter reports 2).
Draining a monster loses Hit Dice symmetrically: the instance re-derives THAC0 and
saves from the reduced HD and loses a rolled d8; below 1 HD it dies. The spawn
consequence (rises as a wight in 1d4 days) is a structured-but-manual field on the
drain event — the kernel kills, the game narrates. Locked by test_drain.py.
The damage pipeline order
Gate → roll (+ STR for melee) → doublings (brace, charge, back-stab) → minimum 1 →
reductions (floored, never below 1). If the defender's harmed_only_by or energy
defenses exclude the source, no damage is rolled and the event says so. Save-for-half
halving also floors. Back-stab adds +4 to hit and doubles damage, both halves from
the thief tag's params, when the caller asserts an unaware target attacked from
behind. Locked by test_combat.py::TestDamagePipeline.
The WIS save modifier applies to magical non-breath saves only
Pinned reading of "does not normally include saves against breath attacks"; referee
discretion beyond that arrives as a caller modifier. Locked by
test_combat.py::TestSavingThrows.
hd5_counts_as_magical implements the whole invulnerabilities optional rule
The spec's flag summary names the 5+ HD half as shorthand; the flag governs both
bullets of the SRD optional rule it cites — a monster of 5+ HD and another
invulnerable monster bypass the gate — since implementing half an optional rule would
be an undocumented deviation. Boundary pinned to the rule's own wording: the flag
touches only weapon-material gates whose keys are a subset of {silver, magic}, and
"another invulnerable monster" means a monster bearing such a gate — element-keyed
and mixed gates (the mummy's fire-or-magic, the black pudding's fire-only) are
unaffected. Locked by test_combat.py::TestDamagePipeline.
The holy damage key is admitted through any gate on undead targets — and only there
"Holy water inflicts damage on undead monsters": the specific rule overrides the
general immunity, otherwise the wight's silver-or-magic gate would absorb the one
weapon made for it. Against anything living, the throw resolves normally and the
damage pipeline reports no effect — deliberately not a validator rejection:
rejections are free (no roll, no time, no log entry), and a rejection would be a
zero-cost undead detector. Locked by test_combat.py::TestSplashWeapons.
Variable weapon damage off means 1d6 for weapons and gear facets
RAW: "PC attacks inflict 1d6 damage" — weapons and gear combat facets. Unarmed
attacks stay 1d2 (the specific unarmed rule, not a weapon) and monster damage is
unaffected (monsters "deal the damage indicated in the description"). Locked by
test_combat.py::TestDamagePipeline::test_variable_damage_flag_off.
Splash damage is two applications
"Inflicted for two rounds" is pinned as two applications: the hit's damage now, and
the douse effect's expiry applies the listed damage once more at the next round
boundary. Unlit oil deals no damage (the caller may compile it into a
location-attached pool: 3-foot pool, burns 1 turn once lit, 1d8 to creatures passing
through — who passes through is the caller's assertion until Phase 4 owns space);
uses_fire monsters ignore burning oil outright. Locked by
test_combat.py::TestSplashWeapons.
Torch and burning oil deal fire damage; holy water carries the holy key
The three dual-listed gear items carry pinned damage-source semantics — this is what
routes a torch or oil hit into the troll's non-regenerable ledger. Locked by
test_combat.py::TestSplashWeapons::test_douse_applies_twice_then_expires.
Breath-weapon daily limits are per-monster data
The three-per-day limit is per-monster data (the dragons, the dragon turtle, and the
chimera print it; the hellhound has none — its 2-in-6 per-round chance ships as data
for Phase 4's action policy, and the kernel resolves the breath whenever invoked).
Every breath weapon is a destructive death (pinned): the SRD's destruction-of-items
examples ("a lightning bolt spell or a dragon's breath") illustrate energy deaths
generally, so the hellhound's fire kill destroys the victim's mundane equipment too.
Dragons' energy immunity encodes as immune-to-nonmagical plus
auto-save-versus-magical for the variant's breath element(s). Locked by
test_combat.py::TestBreathAndGaze.
The HD-budget targeting mode consumes weakest-first
Candidates sort weakest-first by effective HD — sub-1 HD rounds up to 1, fixed
hit-point bonuses are dropped, characters count their level — ties broken by stable
input order; the budget spends whole creatures, and a target whose HD exceed the
remainder is skipped while selection continues. Sleep's exact arithmetic lands with
the spell in Phase 3. Locked by test_combat.py::TestTargeting.
Monster XP validation: above-21-HD inflation and the negative-modifier band
Printed XP is authoritative and cross-validated at compile time: base by HD row plus
asterisks × the bonus column, where above 21 HD both amounts first gain 250 per HD
above 21 — the dragon turtle (HD 30, XP 9,000) proves the reading. Negative
hit-point modifiers map to the lower* band — the goblin's 1-1 validates against
"Less than 1" (XP 5) while keeping the unmodified "Up to 1" attack-matrix row (THAC0
19 [0]); the attack-as-1-HD-higher rule is for bonus modifiers only. Table mismatches
fail the build unless recorded as overrides. Locked by
test_monster_data.py::TestCombatTables.
Poison failure is death, with optional onset; riders leave the damage unaffected
Save versus death/poison; failure is death — immediate, or an onset-delay effect for
forms like the giant rattler's "death in 1d6 turns". Poison riders on damaging
attacks leave the damage unaffected by the save, per RAW. Locked by
test_effects.py::TestLedger::test_delayed_death_poison.
Petrification suspends the target's other effects
While a target is petrified its other attached effects suspend — no ticks, durations
frozen: a poisoned, petrified adventurer is a problem for after stone to flesh.
Petrification is permanent but recoverable — stone is not dead. Locked by
test_effects.py::TestLedger::test_petrification_suspends_other_effects.
The canonical tick order
At each round boundary, expirations resolve before ticks; simultaneous effects
resolve in attachment order, tie-broken by effect id. Only the effects engine (and
the kernel's death routine, for dead) writes creature conditions. Locked by
test_effects.py::TestLedger.
Troll damage splits into regenerable and non-regenerable ledgers
Fire and acid accrue in a separate non-regenerable ledger on the instance;
regeneration never heals that ledger, and the troll is permanently dead only when
non-regenerable damage alone reaches max HP — otherwise death at 0 hp schedules a
2d6-round revival (the effects stream), anchored to the round the killing damage
landed, with the revived troll rising at 1 hp. Locked by
test_effects.py::TestRegeneration and the troll battle golden.
Mummy rot heals naturally once per ten full rest days and blocks magical healing
"Magical healing is ineffective; natural healing is ten times slower" — one 1d3
recovery per ten consecutive full rest days, tracked on the effect; removal is Phase
3 magic. apply_healing defaults its source to magical (instantaneous healing is
magical healing per the spec), so a caller that forgets to name the source still
respects the block. Locked by test_effects.py::TestMummyRot.
Asleep grants auto-hit and dies-to-a-blade; dozing is a caller modifier
The asleep condition's combat hooks ship now (the sleep spell that inflicts it is
Phase 3): a sleeping defender is hit automatically in melee, and "a single attack
with a bladed weapon can kill" — the melee hit kills outright with no damage roll.
Pinned: "bladed" means a weapon (not a gear facet, a monster's natural attack, or an
unarmed strike) with the melee quality and without the blunt quality — the SRD's
blunt list exists precisely to separate crushing weapons from edged ones; the hook is
melee-use only, and the immunity gate still applies first (a sword absorbed by the
black pudding's fire-only gate kills nothing). The dragons' "may be attacked for one
round with a +2 bonus" dozing is the caller-supplied situational modifier, not a
condition. Locked by test_combat.py::TestDamagePipeline::test_sleeping_defender_dies_to_a_blade
and its neighbors.
Falling damage floors per full 10 feet
1d6 per full 10 feet fallen; a 9-foot fall rolls nothing. Locked by
test_combat.py::TestHealingAndFalling.
Reload is enforced from caller context
A reload-quality weapon may not fire two rounds running: under the weapon_reload
flag the attack validator rejects when the caller-supplied context says the weapon
fired last round. Round bookkeeping is the Phase 4 battle machine's job — the kernel
enforces the rule given honest context. Locked by test_combat.py::TestValidateAttack.
Two-handed weapons and shields conflict at equip time
Wielding a two-handed weapon with a shield equipped — or equipping the second of the
pair — rejects with items.equip.two_handed_with_shield, pinned at equip time rather
than silently ignoring the shield at resolution. Locked by
test_items.py::TestTwoHandedShieldConflict.
Monster spawn HP is minimum 1, ½ HD is 1d4, fixed forms are exact
Spawned HP is the sum of the HD count in d8s (d4 for ½ HD) plus the signed modifier,
minimum 1; 1hp and the hydra's 8-hp-per-HD forms roll nothing. Locked by
test_monster_data.py::TestSpawnHitPoints.
Compiled monster data conventions
No hit roll required AC means attacks auto-hit; Varies morale compiles to none;
packed-variant pages expand to concrete entries under enumerated ids (a frozen
template must be spawnable); undead auto-tags from the standard verbatim bullet
with the shadow excluded ("incorporeal (but not undead)"); person comes from the
General.md §Persons list (the SRD's own pinned default for a referee-judgment
rule); enchanted is hand-curated via overrides with a reason per entry; embedded XP
values are authoritative; compound alignments compile to an options model;
leader/chieftain XP compiles to structured notes with stats left as prose. Locked by
test_monster_data.py.
The event discriminator is event_type, with outcome-bearing codes
Every kernel event class declares a single-valued event_type Literal (snake_case,
schema-stable, additive-only) that the KernelEvent union discriminates on; message
code stays free to be outcome-bearing from a per-class closed set
(combat.attack.hit / combat.attack.missed), so formatters key off codes while
consumers discriminate on event_type and skip unknown values mechanically. The
default English formatter is total: unknown codes format to the code string itself.
Locked by test_events_kernel.py.
New RNG stream conventions
Phase 2's stream keys are "combat" (battle resolution: attacks, damage, saves,
morale, initiative), "effects" (effect-internal randomness: durations, onsets,
revival delays, natural-healing rolls), and "monster_spawn" (spawned hit points) —
scoped so a combat-rules change never shifts spawned HP or effect draws in a golden.
Locked by the Phase 2 milestone goldens.
Spell ids are page-derived slugs, dual pages take _c/_mu, reversed forms are entry data
A spell's id is the slugified primary name with the reversed parenthetical dropped
(cure_light_wounds); the nine concepts printed as separate (C) and (MU) pages
compile as two entries with _c/_mu suffixes because the pairs differ mechanically
(hold person is 2nd-level cleric at 9 turns/180' but 3rd-level magic-user at 1 turn
per level/120'), and a reversible spell's reverse is a ReversedForm on its entry,
never a separate catalog entry. The invisible stalker page's (MU) marker
distinguishes the monster page, not a dual pair — its id stays plain. Locked by
test_spell_data.py::TestCensus.
Spells.md is the single magic-rules source, and spell pages are identified by the level line
Rules_of_Magic.md carries identical content and is never read. Spell pages are
identified by the italic level line (*3rd Level [Magic-User Spell](...)*) — never by
the **Duration:**/**Range:** labels, which also appear on potion, hazard, and
water-vessel pages. Locked by the compiler census (106 pages, 34 + 72 list entries).
Memorization is a full-replacement preparation with the timing gates deferred
memorize_spells models the daily preparation as a whole-list replacement. The
once-a-day/after-uninterrupted-sleep/one-hour gates are exploration procedure owned by
the Phase 4 crawl layer; standalone users call it freely. Duplicate selections are
legal per RAW. Locked by test_memorization.py::TestMemorizeSpells.
Arcane casters fix normal/reversed at memorization; divine casters choose at cast
Per Spells.md: the arcane form "must be selected when the spell is memorized", while
divine casters reverse "when it is cast" — so divine copies never carry a reversed
flag (magic.memorize.divine_reverses_at_cast) and a divine reversed cast consumes
any copy of the spell. Locked by test_memorization.py and
test_casting.py::TestCastingPipeline.
Casting consumes the first matching memorized copy, and touch attacks consume it hit or miss
Consumption takes the lowest tuple index; disruption removes a copy identically ("as
if it had been cast"). Cause wounds touch attacks roll a melee attack only in combat
("In combat, a melee attack roll is required" — the pinned inverse: no roll outside
combat), and the copy is spent whether or not the touch lands — nothing in RAW holds
the charge. Locked by test_casting.py::TestCuresAndRestoration.
The holy symbol is not a mechanical gate on casting or turning
Cleric.md states "A cleric must carry a holy symbol" as a class edict alongside
deity faithfulness, not as a precondition on any procedure. The item ships in the
Phase 1 gear data; games wanting the stricter reading check inventory themselves.
Consequence chosen: a symbol-less cleric turns and casts unimpeded. Locked by
test_turning.py::TestValidation::test_holy_symbol_is_not_a_gate.
Starting books hold exactly capacity; book capacity is per level; books never auto-shrink
Arcane casters begin play with as many spells as they can memorize (exactly — one
first-level spell at level 1); clerics start with nothing (their level-1 row has no
slots). add_spell_to_book caps the book, per spell level, at the current slot count
for that level (the RAW "exactly the number of spells that the character is capable of
memorizing", read per level). The book is a physical object and never auto-shrinks:
a drained character may hold a book over capacity and simply cannot add more. Drain
forgets excess memorized copies newest-first per level (RAW is silent; newest-first
is deterministic without new state). Locked by test_memorization.py.
Category and immunity gates resolve as no-effect, never as rejections
Extending the Phase 2 holy-water doctrine: rejections are free and would leak hidden
state — casting charm person at a disguised doppelgänger or sleep at a wight must
not be a zero-cost detector. Casting at an ineligible target consumes the copy and
reports magic.cast.no_effect; immune and excluded creatures consume no HD budget
(exclusion happens inside resolution, not as a validator refusal). Locked by
test_casting.py::TestCastingPipeline::test_no_effect_still_consumes_the_copy and
TestSleep.
Sleep's mode arithmetic
Mode 1's "a single creature with 4+1 Hit Dice" is pinned as a monster with HD count 4
and a positive fixed modifier; mode 2 ("2d8 HD of creatures of 4 HD or less") excludes
exactly those, costs each creature its HD count with fixed bonuses dropped (the page's
own "3+2 counts as 3") and sub-1 HD rounded up to 1, spends weakest-first with
stable-order ties through the Phase 2 resolver, and grants no saving throw. Locked by
test_casting.py::TestSleep.
Magic missile is 1 + 2×⌊(level−1)/5⌋ missiles, resolved instantly
Pinned from "three missiles at 6th–10th level, five missiles at 11th–15th level". Each
missile takes one supplied target (repeats stack), hits unerringly with no attack roll
and no save, and resolves at cast — the printed 1-turn duration is holding prose.
Locked by test_casting.py::TestMagicMissile.
Lightning bolt kills destroy equipment; disintegrate kills permanently and destroys carried gear
Lightning bolt is a destructive death source (closing the Phase 2 forward
reference). Disintegrate destroys "the material form ... instantly and permanently":
pinned as permanent death plus equipment destruction — the material form includes what
it carries. Locked by test_casting.py::TestFireBallAndLightningBolt and
TestSaveOrDie.
Spell damage presents the magic key
A damage-dealing spell passes weapon-material gates whose keys include magic: a
wight's silver-or-magic gate admits magic missile, a gargoyle's magic-only gate
admits fire ball. Discovered in implementation (the plan was silent); without it the
one attack form made for gated undead would bounce off them. Locked by the Phase 3
spell-battle golden (three missiles damaging the wight).
The hold/charm person gate admits any character and only person-category monsters
Undead are never affected (their exclusion resolves as no effect). Hold monster and
charm monster affect anything but undead; charm monster's single mode takes more
than 3 HD and its group mode 3d6 creatures of 3 HD or less. Locked by
test_casting.py::TestHoldAndCharm.
Charm re-saves are monthly/weekly/daily by INT, with month = 30 days
INT 3–8 saves monthly, 9–12 weekly, 13–18 daily; a month is pinned to 30 days and a
week to 7. Monsters have no INT score and default to the middle weekly band
(override-correctable per monster). The re-save is a tick-time draw on the effects
stream; a passed save releases the charm. Locked by
test_casting.py::TestHoldAndCharm.
The cumulative-effects rule: largest bonus plus largest penalty per statistic
Spells.md: "Multiple spells affecting the same game statistic do not combine."
Pinned: at consultation time only the single largest bonus and single largest penalty
apply — a bless and a blight offset; two blesses don't stack. Spell modifiers
combine freely with non-spell sources (the RAW carve-out for magic items, Phase 5).
Spell morale modifiers ride check_morale's existing modifier argument inside the
Phase 2 ±2 clamp and ML 2/12 exemptions — one uniform adjustment rule, not a second
channel. Locked by test_casting.py::TestBuffsAndWards and
test_spell_properties.py.
Shield's AC values apply as better-of
"AC 2 [17] against missiles, AC 4 [15] against other attacks": the target's effective
AC is the better of their own and the set value, never worse — a plate-armoured target
keeps plate. Locked by test_casting.py::TestBuffsAndWards.
Striking applies to weapon attacks of any kind, never unarmed, and attaches to the wielder
RAW names no weapon type, so missile weapons qualify; unarmed strikes gain nothing.
The enchantment attaches to the wielder because item instances carry no ids — the
corner case of handing the enchanted weapon away mid-duration is accepted. The wielder
counts as magical for weapon-material gates while it runs. Locked by
test_casting.py::TestBuffsAndWards.
Protection from evil: the melee ban ships as data, and monster alignment resolves at spawn
The enchanted/constructed/summoned melee ban is structured params consumed by Phase
4's battle machine (the kernel can't see who initiates melee). The ward's ±1 applies
against creatures of another alignment, which needs a resolved alignment where
MonsterTemplate.alignment is multi-option: MonsterInstance.alignment resolves at
spawn (caller's choice, else the template's usual, else its sole option), and an
unresolved alignment counts as differing — the ward errs protective. Locked by
test_casting.py::TestBuffsAndWards.
Small nonmagical missiles are character weapon missiles and thrown splash items
Protection from normal missiles' boundary, pinned from the page's own examples
("no protection is granted against hurled boulders or enchanted missiles"): missile
weapon uses and thrown splash items set the new DamageSource.missile flag; monster
attacks are never auto-marked, with AttackContext.monster_missile as the caller's
opt-in when the fiction says small missile (a hobgoblin's arrow). Locked by
test_casting.py::TestBuffsAndWards::test_protection_from_normal_missiles_boundary.
All spell-attached effects are dispellable, monster effects are not, and survival is 5% per level of deficit
RAW says dispel magic "ends spell effects", full stop: permanent=True means "no
duration expiry", not "undispellable" (continual light and flesh to stone can be
dispelled). Effects attached by cast_spell record the caster's level; per effect,
when the recorded level exceeds the dispelling caster's, it survives on a d100 at or
under 5% per level of difference. Monster-inflicted effects (poison, rot,
regeneration) are non-dispellable by construction; magic items are exempt (Phase 5).
Locked by test_casting.py::TestSilenceWebDispelFeeblemind.
Raise dead: 0 days at level 7, a 14-elapsed-day weakness, and permanent undead destruction
The time limit is 4 days per caster level above 7th — 0 days at level 7, RAW-faithful.
Revival sets 1 hp and attaches the weakness, pinned to 14 elapsed days as the
simplification of "two full weeks of bed rest" (rest tracking is Phase 4 procedure;
games wanting strict bed-rest semantics extend or release via the ledger). While it
runs, the subject cannot attack, cast, or turn undead (RAW bans "other class
abilities" too), moves at half rate (a Phase 4 marker param), and healing from
every source is blocked — RAW says the subject "has 1 hit point" until the
recovery completes and no magical healing shortens it; the hit point returns to
normal recovery when the weakness ends. Only characters are raisable (all four
Classic races are human or demihuman; monsters are not). The destroy-undead usage
kills permanently on a failed save, matching turning's D. Locked by
test_casting.py::TestCuresAndRestoration::test_raise_dead_level_seven_allows_zero_days.
Neutralize poison's revival window is caller-attested, and only characters revive
"A character who has died from poisoning can be revived, if neutralize poison
is cast within ten rounds": the kernel has no cause-of-death model, so supplying
CastContext.rounds_since_death is the caller's attestation that the target died
of poison within that many rounds — omit it for any other death. Only a Character
is revivable (the page's usage is titled "Characters"); revival stands the subject up
at 1 hp (pinned — RAW names no hit point total). Locked by
test_casting.py::TestCuresAndRestoration.
Silence automates both forms: moving on a failed save, stationary on a passed one
A failed save attaches silenced (the casting validator consumes it) and the silence
moves with the creature. On a passed save RAW leaves a stationary area the creature
can step out of: since Phase 4 gave location-bound effects real cells, the
save-passed outcome attaches a stationary silence effect to the target's cell —
pinned as the party's current cell, the encounter's abstract location — and
creatures in that cell cannot cast while there, battles included (the Phase 3
registered gap, closed). Locked by
test_casting.py::TestSilenceWebDispelFeeblemind,
test_exploration.py::TestStationarySilence, and
test_battle.py::TestSilencedCellInBattle.
Web escape tiers key off STR, with the augmented and giant tiers caller-asserted
Normal strength escapes in 2d4 turns (rolled at attach on the effects stream);
magically augmented STR above 18 in 4 rounds and giant strength in 2 rounds, asserted
via CastContext.strength_tiers until such effects exist. The flammable-cube and
blocking rules stay location prose for Phase 4. Locked by
test_casting.py::TestSilenceWebDispelFeeblemind.
Feeblemind targets only arcane_magic casters
The page names "an arcane spell caster (e.g. a magic-user or elf)": the gate is the
target bearing the arcane_magic class tag; anything else resolves as no effect.
Locked by test_casting.py::TestSilenceWebDispelFeeblemind.
The turning column is the HD count, 2* only at count 2, counts above 9 unturnable
A monster's column is its hit_dice.count as a string, except count 2 with a special
ability (asterisked HD) maps to 2* (the table's own footnote), counts 7–9 share
7-9, and counts above 9 have no column — turning fails (the printed table is the
rule; the "referee may expand" footnote is game data territory). Modifiers never shift
columns (the mummy's 5+1 turns on column 5) and the asterisk matters only at count 2
(the wight's 3* is column 3). Locked by test_turning.py::TestColumnMapping and the
verbatim table fidelity test.
The turning pool spends lowest-first, stops at the first unaffordable monster, and always affects one
One 2d6 turn roll compares per candidate type against its cell; on any success one
2d6 HD pool follows. Eligible monsters are affected lowest-HD-first (stable input
order on ties), each costing its HD count (minimum 1, fixed bonuses dropped — the
sleep convention); the pool stops at the first unaffordable monster (RAW: excess Hit
Dice "are wasted", not reallocated); at least one undead is always affected on a
success, pinned to the cheapest eligible monster. D results are permanent
destruction; the rest gain an indefinite, non-dispellable turned condition the
encounter releases. Only undead-category monsters are candidates; non-undead resolve
as unaffected. Locked by test_turning.py and test_spell_properties.py.
The magic RNG stream, and the attach-time/tick-time split
Every draw inside spell resolution — targeting dice, damage dice, touch-attack rolls,
cast-time forced saves, dispel survival rolls, and both turning rolls — comes from the
new "magic" stream, so a combat-rules change never shifts spell goldens and vice
versa. Effect-internal draws (rolled durations at attach, web escape dice, tick-time
saves such as the charm re-save) stay on the "effects" stream per the Phase 2
convention — which is why cast_spell takes both streams. Phase 3 adds no Ruleset
flags: every choice is rules-as-written or a pinned interpretation, never a
referee-optional variant. Locked by the Phase 3 milestone goldens.
Encounter tables: hand-mapped ids, table counts override, variant pools
The encounter-table cell names hand-map to compiled monster ids with compile-time
resolution (the printed links are unresolvable), and the printed count dice override
the monster description's number appearing, per the SRD's own note. Packed-variant
cells (Veteran, Vampire, Hellhound) compile with the full id tuple and each
spawned individual picks uniformly on the wandering stream; the two hydra cells
carry variant_dice — the printed HD dice roll once on the wandering stream and
select the template. NPC-adventurer rows compile faithfully as structured
npc_party entries and re-roll at runtime until Phase 5 builds the parties, draws
consumed. The "Basic Adventures" typo and the singular "Expert Adventurer" labels
normalize via overrides/encounter_tables.json with provenance. Locked by
test_encounter_data.py and test_exploration.py::TestWandering.
Encounter-table and reaction models live in core/tables.py
Pinned for layering: osrlib/data/ loaders import their model homes and core
modules import the loaders, so a crawl/ model home would give
load_encounter_tables() a core → data → crawl transitive import. The reaction
table clamps totals into its outer bands ("2 or less" / "12 or more") and reaction
rolls are referee-visible (players learn reactions from behavior, the morale
precedent); a dungeon level's encounter table is its number clamped into the
printed bands (1, 2, 3, 4–5, 6–7, 8+). Locked by test_encounter_data.py and
test_kernel_checks.py::TestRollReaction.
The crawl streams
Phase 4's stream keys are "wandering" (the check die, the d20, count dice,
variant picks, NPC re-rolls), "encounter" (surprise, distance, reaction,
distraction), "exploration" (forcing, listening, searching, trap springs and
trap resolution, tinder, thief skills), and "monster_action" (every policy draw —
a substituted policy never shifts attack or damage draws). Trap resolution draws
(saves, damage, volley counts) run on the exploration stream — the procedure owns
its dice — while attach-time condition durations stay on the effects stream per the
Phase 2 convention. Locked by the Phase 4 delve golden and
test_battle.py::TestDefaultPolicy.
Detection checks: precedence, zero-chance, and pick-pockets arithmetic
The detection-chance precedence is thief row, then class tag, then the universal
1-in-6 baseline — except construction tricks, dwarf-only with a zero baseline
(the SRD grants that perception to dwarves alone). A zero chance consumes no draw.
Pick pockets caps at 99 ("always at least a 1% chance of failure"), takes the
victim's over-5th-level penalty as a caller modifier, and a roll of more than twice
the effective chance is noticed. Locked by test_kernel_checks.py.
The time-cost census and the thirds odometer
Zero time: TurnParty, ReorderParty, OpenDoor, CloseDoor, WedgeDoor,
ForceDoor (a moment of violence; the noise is the cost), ListenAtDoor (the
once-per-door cap is the limiter), equip/unequip, DropItems,
ExtinguishSource, PurchaseEquipment (town time is abstract), and all referee
commands. One round: CastSpell and TurnUndead outside battle, LightSource
(per tinder attempt too, RAW). One turn: Search, InspectTreasure,
RemoveTreasureTrap (pinned symmetric; RAW is silent), PickLock (pinned; RAW is
silent and the skill is delicate work), TakeTreasure (RAW's packing-bags turn),
Rest(turn). On the odometer: MoveParty per cell and UseStairs at one
unexplored-cell cost (pinned). Stated durations: Rest(night) 48 turns,
Rest(day) 144, PrepareSpells 6, travel per content. Movement accrues in
thirds-of-feet — an unexplored cell 30 units, an explored cell 10, implementing the
SRD's three-times-through-familiar-areas rule exactly — and turn-costing actions
snap the clock to the next boundary, absorbing the partial move. Locked by
test_exploration.py::TestTimeCostCensus and TestOdometer.
Rest, fatigue, sleep, and provisions
A living entangled member blocks party movement (the party doesn't abandon its own
implicitly; leaving someone is a referee-command decision). Fatigue (−1 attack, −1
damage, engine-written) attaches after six consecutive unrested turns — the dungeon
rule; town and travel time don't accrue — and a completed rest turn clears it. A
night is 48 turns (the SRD has no time-of-day model, so a night is a duration);
preparation requires an uninterrupted night's sleep, happens at most once per
sleep, and costs six turns for divine and arcane casters alike; a wandering
encounter mid-preparation doesn't void the already-written memorization (pinned).
An interrupted rest day heals nothing (RAW). Provisions consume standard rations
before iron at day boundaries (fresh food spoils first; RAW is silent) and a
carried waterskin satisfies the day (per-pint bookkeeping is below the simulation
floor). In town, provisions consume but never run short. Locked by
test_exploration.py::TestRestAndFatigue and TestProvisions.
Doors: forcing, noise, swing-shut, locks
Any door-forcing attempt sets the noise flag for the next wandering check (the
banging is identical either way) while only a failed attempt denies the party
surprise against the room beyond (RAW, exactly as written). Doors the party opened
swing shut when the party is no longer in either adjoining cell unless wedged —
RAW's "likely to swing shut" becomes always, making spikes matter; a forced stuck
door is stuck again once shut. A failed pick-lock locks that thief out of that lock
until the next level; listening is once per character per door, free, and rolled
regardless of occupants — undead make no noise, so silence is genuinely ambiguous
(the no-leak doctrine applied to exploration). Locked by
test_exploration.py::TestDoors and TestListening.
Searching and traps
Searching covers the party's current cell (the 10' × 10' area is the cell), costs a
turn, is once per character per cell per kind, is rolled regardless of contents,
and reveals every matching hidden feature on the cell. Treasure-trap find and
removal are once per trap per character each, and a failed removal springs the trap
(the classic reading; RAW says only "attempted once per trap"). Found traps stop
springing on movement (the party walks around the known pit); the player-facing
exploration.trap.safe emits only for a known trap whose trigger resolved
without springing — an unknown trap's spring die stays referee-only. A trap
transition (slides) relocates the whole party — the party model has one location
(pinned simplification). The darts volley resolves as one damage application whose
rolls are every dart's die. TakeTreasure delivers contents to the first living
member in marching order, who is also the treasure trap's triggerer (pinned; the
command carries no character). Locked by test_exploration.py::TestSearching,
TestTreasureTraps, and TestTrapResolutionCensus.
Light, darkness, and location-bound effects
Lighting consumes the consumable (one torch, one oil flask) and extinguishing
forfeits the remainder — no partial-burn bookkeeping; relighting starts a fresh
consumable. A light-kind expiry surfaces to players as exploration.light.expired
appended by the session (the ledger's expiry event is referee visibility). The
tinder box gates lighting when the party has no open flame (an active
brightness="flame" effect), 2-in-6 per round, RAW. A darkness-family effect on
any member suppresses the whole party's light while it runs (the printed radii
swallow a marching party) and blocks_infravision disables infravision too.
Darkness gates, the RAW set: Search, InspectTreasure, PickLock, and reading
require light — searching and listening alone also work on the actor's own
infravision; MoveParty in darkness is legal but the party is surprised on 1–3
instead of 1–2 when unlit and not every living member has infravision (the
blind-party adaptation). Visible equals explored-plus-current-cell — the named
simplification of the spec's visible flag. Location effects anchor to
cell:{dungeon}:{level}:{x},{y} references with enter hooks in attachment order:
the burning-oil pool (DropItems oil + LightSource compiles the Phase 2 pool
onto the cell; every living member entering while it burns takes its 1d8),
stationary silence (see the amended Phase 3 entry), and web's blocking cube
(entering attaches the entangled escape countdown; a location-cast web keeps the
spell's own duration on the cell and the escape params ride the effect). Web
flammability, cloudkill, and the walls stay manual, as registered in Phase 3.
Locked by test_exploration.py::TestLight and TestLocationEffects.
Wandering monsters
The chance takes +1 for noise since the last check, +1 for daylight-brightness
light (continual light's data — the torch/lantern flame is the baseline the
printed 1-in-6 already assumes), −1 while resting, clamps to [0, 6] with zero
skipping the roll — the modifier numbers are pinned for RAW's "referee may
increase/decrease". The cadence suspends during encounters and battles and resumes
after. Wandering monsters are never surprised (they come "moving in the direction
of the party") but the party may be. Locked by
test_exploration.py::TestWandering.
The encounter procedure
Every encounter opens with surprise, a 2d6 × 10' distance roll, and reaction —
keyed encounters included (rooms are abstracted onto the same track). Keyed
awareness comes from the area's flag, a failed door forcing alerts the room, the
lit-party rule skips the monsters' surprise roll entirely, and a successful listen
marks party awareness. Parley re-rolls are uncapped, each a fresh roll with the
speaker's CHA modifier. The stance map (RAW's bands assume a referee): 2- attacks
now; 3–5 attacks at the end of the next encounter round absent evasion or
improvement; 6–8 holds and re-rolls at +0; 9–11 passes freely; 12+ friendly.
Offensive acts in encounter mode go through EngageBattle — except turn undead,
which keeps its own pre-battle, encounter-only command, and whose use against
non-undead onlookers provokes battle (pinned). Only attacks/hostile stances pursue
an evading party. One side's surprise is one free round, whichever door battle
enters through: surprised monsters become the party's free battle round (they
hold while the party acts), and a surprised party's lost encounter beat lets
hostile or attacking monsters open battle with their machine-run surprise round.
An encounter consumes at least one full turn — max(next turn boundary, start +
one turn), which may itself land mid-turn when the encounter started mid-turn
(pinned) — and releases
turned effects when it ends; every effect on the encounter's monsters releases at
its end — the fiction moves on (a dead troll's pending revival is post-battle
narration); an evaded keyed encounter re-triggers fresh on the next entry. Locked
by test_encounter.py, the free rounds by
test_encounter.py::TestSurpriseFreeRounds.
Evasion and pursuit
Pursuit compares the party's slowest running rate (full feet per round) against
the pursuers' slowest base ground mode (the mode with no descriptor, else the
first printed — flying reads dungeon ceilings); slowest-of-pursuers mirrors
slowest-of-party, and a pack that strings out is fiction. Evade(drop="treasure")
scatters every living member's coins and "food" one ration per member (pinned);
mid-pursuit DropItems re-baits round by round. The distraction is 3-in-6 when
the bait matches: treasure for monsters whose treasure ref carries letters — the
intelligence proxy, override-correctable per encounter — food otherwise; one roll
stops the whole side (pack behavior). Pursuit ends on distraction success, at
melee contact (gap ≤ 5', battle at 5'), or at 30 rounds — both sides spent, the
monsters give up (the terminal escape valve; with the party no faster the gap
never grows, so no sight-loss distance is needed) — with running exhaustion (−2
attack, damage, and AC — the AC penalty rides attack_penalty_of_attackers +2)
until three rested turns. Locked by test_encounter.py::TestEvasionAndPursuit.
The battle round
Battle declarations arrive as one command per round with one declaration per
living, able member, rejected whole on any invalid declaration (partial acceptance
would tangle the replay contract). Formation width is 3 in a keyed area and 2 in
corridor under formation_width_limit (RAW's "2–3 characters in a 10' passage"
pinned to 2, rooms at 3); ranks recompute from the living marching order at round
start and melee reaches the front rank only, both directions, with no
firing-into-melee penalty (none exists in the SRD). The party moves as a
formation — individual members cannot leave it (the Bard's Tale convention):
all-retreat moves off at the full encounter rate (Combat.md's "full encounter
movement rate"; the running pursuit begins when the battle converts at round
end), all-fighting-withdrawal backs off at half encounter rate, and the first
close declaration advances the formation on its named group, stopping at 5'. A party melee attack lands on the
first living, visible monster of the group's reachable rank (deterministic, no
draw); monsters pick uniformly on the monster-action stream. Under individual
initiative the machine still resolves side blocks, ordered by the best individual
total (the SRD's phase sequence is per side). Turn undead resolves in the magic
phase, needs no declaration posting, and cannot be disrupted — a class ability,
not a spell. The machine detects disruption per the RAW trigger (a declared caster
successfully attacked or failing a save before acting) and releases concentration
on any other declared action. Each battle round advances the clock one round
through the ledger. Locked by test_battle.py.
Phase 3 effect consumption in battle
Haste resolves the bearer's attack declaration twice and doubles track moves
(the party's consolidated move doubles when every living member bears the
multiplier, pinned); invisible combatants are untargetable — a rejection here
leaks nothing, you know what you can't see — and the machine releases the effect
when the bearer attacks or turns undead; mirror images pop one per incoming attack
with no attack roll; confusion overrides declarations with its 2d6 behavior roll
on the combat stream (a forced rules roll, not a policy choice) and re-saves on
the magic stream per its params — machine-run round rolls, not ledger ticks, so
the Phase 3 tick-time convention is untouched; entangled blocks declared and
policy moves while an entangled front-ranker still fights; weakened cannot
attack or cast (the kernel validators reject, the machine surfaces them). The
protection from evil melee ban: a monster whose template bears a warded category
may not initiate melee against a warded target of differing alignment — the
default policy's target pick skips them, missiles and breath land — and the ban
breaks for a target who has engaged the barred creature in melee, the ±1 modifiers
persisting (RAW's own clause). Locked by test_battle.py::TestEffectConsumption.
The area footprint
An area effect targets one group; its capacity in creatures is
ceil(span / 10) × width, where span is the diameter (radius shapes), length
(lines, cubes, clouds), or reach-limited length (cones: length minus the gap), and
width is the formation width (unbounded with the flag off). Candidates fill in
stable spawn order up to capacity; under aoe_friendly_fire, an area landing on a
group at melee range appends the engaged party front rank after the monsters, in
marching order, consuming remaining capacity. Breath weapons against the party
cover ceil(reach-limited span / 10) ranks from the front; single-target breath
(the hellhound) picks uniformly from the front rank on the monster-action stream.
Locked by test_battle.py::TestAreaFootprint.
The default action policy and morale
The policy draws only from the monster-action stream. Monsters with a scripted
pattern in their data follow it: an uses_per_day breath weapon opens with breath,
then breath or melee with equal chance while daily uses remain (the dragons, RAW);
a per_round_chance_in_six gate rolls each round (the hellhound). Otherwise a
group beyond 5' closes and at 5' melees; monster missile routines lack structured
range data and stay close-then-melee (pinned); groups never cast (casting tags are
manual data). Morale is auto-invoked at the start of each monster block through a
per-battle tracker: first death and half incapacitated, conditional alternates by
round context (fear-of-fire when the round's damage included fire), the spell
morale modifier folded per the Phase 3 rule; ML 2 groups rout when battle starts
(none exist in the compiled data — the branch is test-forced); a failed check
means retreat exposure that round, then flight at full speed, leaving the battle
past 120' (the pursuit sight figure, symmetric) as routed. Chasing routed monsters
is game territory (an evasion procedure with roles swapped, deferred). A group
whose living members are all turned or afraid retreats as a whole; individually
turned members of a mixed group simply don't act (pinned). Locked by
test_battle.py::TestDefaultPolicy and TestMoraleAndEnds.
The session's honest context
Character ids assign as character-NNNN from the session allocator in party order
(closing the Phase 1 seam). Dead members stay in the party but count for nothing.
The session records each character death's clock round and cause — poison when
the killing resolution was a poison save or poison-delay expiry, else the source
kind — feeding raise dead's day count always and neutralize poison's round
window only for poison deaths, replacing the Phase 3 caller attestations. Session
flags are referee-only (content wiring is the game's secret) and neither view
carries the seed (it lives only in the save); the player view is the enumerated
whitelist locked by the leak property test, with undiscovered secret doors
rendering as walls. SetDoorState emits referee-visibility door events;
PlaceParty triggers no hooks, traps, or keyed encounters (a referee tool);
referee AdvanceTime runs full bookkeeping but no wandering cadence (the referee
controls encounters); SpawnMonsters rolls its count dice on the encounter stream
and opens a standard encounter with both sides rolling surprise — in a dungeon
only (the combat space needs a party cell), and never while an encounter is
already running; PlaceParty likewise rejects mid-encounter. Locked by
test_session.py and test_crawl_properties.py.
Persistence and replay
Saves are self-contained (adventure content embedded) with the accepted-command
log always and the event log optional; the event log preserves unknown event types
losslessly (the log is a record, never re-derived, never lossy). Replay under a
different engine version raises ReplayVersionError; loading a save across engine
versions remains legal. The migration framework ships exercised by a synthetic
in-test migration at schema version 1. The standing test: load(save) equals
replay(seed, commands) for the delve golden at every checkpoint. Locked by
test_persistence.py and test_phase4_goldens.py.
The treasure compiler: entry grammar, magic clauses, and TreasureRef semantics
Treasure-type entries parse on a fixed grammar — an optional NN%: presence gate,
quantity dice with multipliers and glued coin suffixes, gem/jewellery counts, and
structured magic clauses (any, a named category, or a pool; a count or count
dice; exclusions; + 1 potion-style extras). Pools pick uniformly among their
categories before the sub-table roll; an exclusion re-rolls exactly the named
master-table category with draws consumed (the wandering-re-roll precedent).
TreasureRef letters resolve by SRD section — parenthetical letters are lair
treasure — extra_gp joins the hoard, multiplier repeats the whole roll,
special and see_below generate nothing, and the small-lair reduction stays
prose for the referee. Gems roll the d20 value table per stone; jewellery rolls
3d6 × 100 gp per piece; both values fix at generation and appraisal is
instantaneous and exact (B/X prices treasure for the XP economy; a haggling or
appraisal minigame is game territory, registered). Locked by
test_treasure_data.py and test_treasure_kernel.py.
Treasure generation order, tiers, and the treasure stream
Generation follows printed-entry order, depth-first per item — each gem's value,
each jewellery's value, each magic item fully resolved (sub-table, armour-type d8
for generic armour, charges at creation, ammunition quantities, scroll contents,
sword sentience last — special-purpose 1-in-20 first, then the printed procedure)
before the next entry — all on the new "treasure" stream, so draws are
reproducible from the stream alone. Tier selects the printed B/X sub-table
columns: the party's highest living level, with 4+ as expert; à la carte callers
choose explicitly and pass their own stream and allocator. Locked by
test_treasure_kernel.py, test_magic_items.py, and the phase 5 hoard golden.
Hoards generate lazily, land as engine-created caches, and are untrapped
A keyed encounter's lair hoard generates when the group first spawns, and lands
as an engine-created cache on the area's first listed cell in the dungeon-state
overlay — authored specs stay frozen; piles and features grow additive valuable
and magic-item fields. Carried treasure generates at spawn (one keyed group's
draw order: carried treasure per printed line, then the lair hoard), and
SpawnMonsters generates none — the referee grants carried loot explicitly.
Area treasure specs generate on first entry; unguarded treasure uses the
Designing-a-Dungeon bands. Generated hoards are untrapped — trapping treasure is
authored content, not a generation outcome. Locked by
test_exploration.py::TestTreasure and the milestone golden.
Loot drops: slain and surrendered sides drop, routed sides keep
At battle's end, slain and surrendered groups drop their carried treasure and
group bundles into a pile on the party's cell; a routed (fled) group keeps
everything and takes it home. Defeated NPC adventurers dump their kit and share
of the group's U + V bundle the same way. Locked by test_encounter.py and the
milestone golden.
The end-of-adventure award is the departure-snapshot valuation delta
EnterDungeon snapshots the party's valuation — coins plus carried valuables at
exact value, magic and mundane gear at zero — and TravelToTown awards the
delta: floored once from copper, clamped at zero, added to the defeated-monster
ledger's XP, divided evenly among living members with floor division and the
remainder dropped, applied through apply_xp (class modifiers and the one-level
clamp apply per member; this supersedes the Phase 4 note that the award would
drive AwardXP). Dead members' carried treasure counts toward the delta but
draws no share; a TPK never awards. The award clears the defeated-monsters
ledger and the snapshot; the Phase 4 recovered-treasure ledger is cut —
SCHEMA_VERSION bumps to 2 and the register's first real migration drops the
field from version-1 saves. Selling in town after the award changes nothing.
Locked by test_award.py and test_persistence.py.
Treasure weight covers coins, valuables, and the five priced magic categories
treasure_weight_coins grows from purse-only to purse + valuables + the magic
items the TreasureWeight rows price (potion, scroll, rod, staff, wand),
closing the Phase 1 seam its docstring named. Rings and miscellaneous items
weigh zero absent a page figure; the Bag of Holding's printed 600-coin loaded
weight counts while it holds anything. Enchanted weapons and armour weigh as
equipment beside their mundane bases — base weight, armour halved per RAW — not
as treasure, so basic encumbrance's treasure tracking stays honest. Locked by
test_items.py and the closed-form property in test_crawl_properties.py.
The magic item catalog: 164 templates, a wired census, and manual prose
The compiler builds every item on the eight printed sub-tables (both B and X
probability columns; the sparse basic column compiles as printed), with
hand-curated mechanics in _MAGIC_ITEM_MECHANICS (the _SPELL_MECHANICS
precedent): the wired census is exactly the plan's work-item-4 list, and
everything else carries manual-tagged prose. Versus clauses compile as
structured category/tag references — lycanthropes and undead as category tags,
spell users and regenerating creatures as ability-derived template sets — never
string-matched prose. The girdle of giant strength is the one wired item whose
mechanics branch on a Ruleset flag (variable_weapon_damage), named as such
in the tests; the regeneration ring gates while_alive; the displacer cloak
penalizes melee attackers only; the energy-drain sword drains automatically on
every hit at the level_minimum XP policy (pinned CRPG default over RAW's "may
command"). Truncated SRD text completes via overrides/magic_items.json with
provenance (helm_of_telepathy). Locked by test_srd_data.py and the
test_magic_items.py census.
Magic items enter play unidentified, identify on first meaningful use, and curses stick
Instances mask behind category display names (an enchanted arm is "a {base} with
a faint aura", a miscellaneous item "a curious device") until identified —
first meaningful use, the referee's IdentifyItem, a worn item's effect
attaching, or being attacked while wearing enchanted defenses (the bonus shows
itself in battle). A cursed item reveals at the same trigger and sticks — swords,
weapons, armour, shields, and rings alike refuse UnequipItem
(items.curse.stuck) until remove curse; a cursed ring reveals the moment it
is equipped (its onset is automatic). Charges are rolled at creation and
referee-only forever after (RAW "undiscoverable"): the player view never shows a
charge count, a potion's remaining duration, per-item state, sentience, or a
hoard not yet found. Locked by test_magic_items.py, test_exploration.py,
and the leak property test.
Item bonuses ride their own channel, outside the cumulative caps
Equipped-item bonuses (enchanted arms, rings of protection, the displacer
cloak) compute from equipped inventory at query time — never as ledger effects —
so unequipping is instant and nothing needs dispelling. Item-sourced ledger
effects (potion durations, wards, the weakness onset) attach with
from_item=True, are undispellable, and sit outside the Phase 3 cumulative
largest-bonus-plus-largest-penalty cap, which applies among spell-kind effects
only: modifier_total is the capped spell contribution plus the summed item
contribution. strength_set, ac_bonus, and the damage-multiplier kinds join
MODIFIER_KINDS. Locked by test_effects.py, test_combat.py, and
test_magic_items.py.
Potions: hidden 1d6+6-turn durations, mixing, and the weekly inversion
A drunk potion runs 1d6 + 6 turns, rolled on the effects stream and hidden from
the player. Drinking while another potion runs cancels both and disables
potions for 3 turns — except instantaneous or permanent forms. Invulnerability
inverts on the clock's day: used more than once in a week, it applies −2
instead of +2, tracked per character. Locked by
test_exploration.py::TestUseItem.
Scrolls: per-spell burn, minimum caster level, the thief fizzle, wards, and curses
Spell scrolls burn per spell cast, resolve at the minimum class level able to
cast the spell per the compiled progression (a _ScrollReader proxy — the
character is never mutated), and waive the arcane magical-script reading gate
(pinned: the scroll itself is the medium). A 10th-level thief reads arcane
scrolls with the printed 10% error chance, resolved as a fizzle — the spell
burns, nothing resolves. A cursed scroll triggers on reading and rolls
uniformly among the six compiled curses (energy drain and slow healing wired,
the rest manual): energy drain resolves through drain_levels at the
halfway XP policy (the curse's own wording), and slow healing doubles the
natural rest-day cadence while halving healing spells, floored — half, not
cause disease's outright block, so the curse is no disease and carries no
condition. Protection scrolls attach one party-wide ward effect to the
reader with per-HD-band counts as structured params
(affected_1_3/affected_4_5/affected_6_plus); the ward bars the listed
creatures from melee (the protection from evil machinery) and breaks for the
whole party when any member melees a barred monster. Locked by
test_casting.py, test_exploration.py, and test_battle.py.
Devices spend a charge and a round, exhaust to inert, and never recharge
Wand, staff, and rod activations spend one charge and the user's action,
resolve in the magic phase through the kernel damage/save path with the item's
printed area shape under the Phase 4 footprint rule, and an exhausted device
goes inert — further activation is a rejection (items.device.inert), never a
silent no-op. Devices never recharge and never show charges. Device areas are
destructive (the lightning-bolt equipment precedent). Locked by
test_battle.py and test_exploration.py.
Rings cap at two, worn on hands
MAX_RINGS_WORN = 2 (RAW: one on each hand), the third rejected at equip
validation (items.ring.hands_full). The ring of protection's 5'-radius
variant extends its AC bonus to the wearer's rank-mates against attacks in
battle — the battle space has no finer adjacency than the rank, and the
printed example ("two characters fighting beside the wearer") is exactly
melee defense. The aura arrives as caller-asserted context
(AttackContext.defender_ally_ac_bonus), never stacks across rings, and the
wearer's own +1 rides the equipped-item channel as usual. Saving throws and
out-of-battle resolution honor the wearer alone, pinned: saves resolve inside
the character-scoped kernel, and outside battle the party has no spatial
model. Locked by test_items.py and test_magic_items.py.
The item streams
Phase 5 adds "treasure" (every generation draw: presence gates, quantity
dice, values, sub-table rolls, charges, sentience) and "npc_party" (NPC
composition: alignment, class rows, level dice, scores, hit dice, spell picks).
Use-time item resolution — device damage and saves, scroll-cast targeting, the
potion-effect dice — rides the "magic" stream beside spells; attach-time
rolled durations and the cursed-scroll curse pick stay on "effects" per the
Phase 2 convention; energy-drain sword drains and the drain curse resolve on
"advancement" beside drain_levels. Locked by the phase 5 goldens and
test_treasure_kernel.py.
NPC adventuring parties: the generation pins
One d6 alignment roll for the whole party, then per member on the npc-party
stream: the d8 class-and-level row, the level dice by kind (basic 1d3, expert
per row, demi-human caps asserted at compile), 3d6-in-order scores with class
ability requirements skipped (the printed d8 row is authoritative — a
requirement re-roll would fight it), the first-level hit die and one level_up
roll per level above first, and uniform spell-slot picks (books match
memorization for arcane casters). Members wear pinned class kits — the SRD's
"Basic equipment appropriate to their class", made concrete — with plate
upgrades for expert clerics, dwarves, and fighters; the dwarf's battle axe is
two-handed, so its kit shield rides carried, unwielded. The kit's generic Axe
and Bow pin to battle_axe and long_bow. Expert members roll Expert magic
items at 5% per level per suitable sub-table (basic parties take none, per
RAW's Expert-only note), unusable rolls ignored, better arms equipped; the
group shares one U + V treasure bundle on the treasure stream. Locked by
test_npc.py and the NPC-party golden.
NPC parties in play: encounter sides like any other
SpawnNpcParty and wandering npc_party rows field a real generated party as
an encounter side (the Phase 4 re-roll is gone; count dice stay on the
wandering stream) — EncounterGroup.monster_ids holds combatant ids spanning
monsters and NPCs. The roster event is referee-visibility; players learn
compositions by fighting. NPC groups check morale at the Veteran's ML 9, are
always intelligent for distraction, and are worth level-as-HD XP recorded under
npc:<class_id>. The default NpcPartyPolicy heals a below-half ally with the
highest cure available, drinks a healing potion when no cure remains, casts
wired offense highest-level-first, shoots beyond 5', and melees otherwise; NPC
casts declare at the round's top and disrupt exactly like the party's. Locked
by test_npc.py and test_battle.py.
Town services: full-value selling and the temple's six services
SellTreasure pays a valuable's exact rolled value to its carrier — the
appraisal-is-exact pin, applied to the market. Magic items have no fixed sale
value and refuse (town.sell.no_fixed_value); pricing one is game territory.
PurchaseHealing offers six services at invented, registered prices — cure
light wounds 25 gp, cure serious wounds 100, cure disease 150, neutralize
poison 150, remove curse 200, raise dead 1,500 — each resolved by casting the
real spell through the kernel at the minimum-able cleric level, honoring the
Phase 3 death-record windows (raise dead's day limit, the 14-day weakness).
Remove curse also unsticks cursed equipment. All services are always available
in the 1.0 marker town (town size and cleric rosters are game territory,
registered). Locked by test_award.py::TestTownServices.
Listener-returned events are authored events
The session listener contract's returned-events channel is for events a
listener authors directly. A listener that reacts by executing referee commands
(the fetch-quest pattern) must return none of those commands' events — the
nested execute already logged them, and returning them would double-log.
Command-log replays therefore run listener-free and converge with the original
event-for-event; the listener's own state store is the game's annex, restored
by saves and re-registered by the game, never re-derived by replay. Locked by
test_session.py and the phase 5 milestone golden.
Documented adaptations
Ruleset-flagged deviations from rules-as-written. The Phase 1–2 flags
(hp_reroll_at_first_level, encumbrance, variable_weapon_damage,
individual_initiative, thac0_arithmetic, weapon_reload,
hd5_counts_as_magical) are SRD optional rules, not adaptations; Phase 4 adds the
register's first true documented adaptations, and Phase 5 adds two more.
deprivation_penalties (default off)
Consumption is tracked regardless; with the flag on, the SRD's "at the referee's
discretion, for example" starvation list gets pinned defaults drawn from its own
examples: after one full day without food or water, −1 to attack rolls (an
engine-written modifier effect) and the rest cadence doubles (fatigue after three
unrested turns instead of six); after two days, movement also halves; from the
third day on, a daily 1d4 hit-point loss ticks on the effects stream. Water and
food deprivation don't stack — the worse track applies. The schedule's numbers are
invented over the SRD's open list. Locked by
test_exploration.py::TestProvisions.
aoe_friendly_fire (default on)
Areas overlapping a melee catch friends: an area landing on a group at melee range
(≤ 5') appends the engaged party front rank after the monsters, in marching order,
consuming remaining footprint capacity. Off means areas never include party
members among a monster group's candidates. Locked by
test_battle.py::TestAreaFootprint.
formation_width_limit (default on)
Corridor width caps combatants fighting abreast: rank width 3 inside a keyed area
and 2 in corridor cells (RAW's "2–3 characters in a 10' passage"). Off lifts the
cap — every combatant may melee and area footprints are unbounded. Locked by
test_battle.py::TestFormationWidth.
magic_item_death_save (default on)
The SRD's referee-optional save for a dead character's magic items, given a
default: when a kill destroys equipment (lightning, disintegration), each
carried magic item rolls against the owner's save value for the source's
category (breath as breath, spells as spells, devices as wands, anything else
as death), plus the item's best combat bonus — the highest of its attack,
damage, and AC bonuses; a cursed item saves at its penalty — and survivors
land in the drop pile instead of vanishing —
EquipmentDestroyedEvent.saved_items names them. Off means items share their
owner's fate, the Phase 3 behavior. Locked by
test_combat.py::TestDestroyEquipment.
xp_award_timing (default on_return)
on_return is RAW: monsters and treasure pay out at the adventure's end, the
departure-snapshot valuation delta divided among the survivors. immediate is
the CRPG convention offered as an adaptation: monster XP at encounter end and
treasure XP at acquisition (take, quest grant), each divided among the living
at that moment, and drops never refund — the return then awards nothing.
Neither timing double-pays. Locked by test_award.py::TestImmediateTiming.