osrlib 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- osrlib/__init__.py +65 -0
- osrlib/core/__init__.py +5 -0
- osrlib/core/abilities.py +447 -0
- osrlib/core/alignment.py +25 -0
- osrlib/core/character.py +768 -0
- osrlib/core/classes.py +827 -0
- osrlib/core/clock.py +129 -0
- osrlib/core/combat.py +2166 -0
- osrlib/core/dice.py +167 -0
- osrlib/core/effects.py +918 -0
- osrlib/core/events.py +781 -0
- osrlib/core/items.py +1724 -0
- osrlib/core/monsters.py +620 -0
- osrlib/core/npc.py +326 -0
- osrlib/core/rng.py +287 -0
- osrlib/core/ruleset.py +135 -0
- osrlib/core/spells.py +2292 -0
- osrlib/core/tables.py +733 -0
- osrlib/core/treasure.py +969 -0
- osrlib/core/validation.py +46 -0
- osrlib/crawl/__init__.py +9 -0
- osrlib/crawl/adventure.py +172 -0
- osrlib/crawl/battle.py +2053 -0
- osrlib/crawl/commands.py +1699 -0
- osrlib/crawl/dungeon.py +725 -0
- osrlib/crawl/encounter.py +774 -0
- osrlib/crawl/events.py +752 -0
- osrlib/crawl/exploration.py +2903 -0
- osrlib/crawl/party.py +96 -0
- osrlib/crawl/session.py +982 -0
- osrlib/crawl/views.py +418 -0
- osrlib/data/LICENSE-OGL.md +99 -0
- osrlib/data/__init__.py +283 -0
- osrlib/data/abilities.json +307 -0
- osrlib/data/classes.json +2827 -0
- osrlib/data/combat_tables.json +734 -0
- osrlib/data/encounter_tables.json +1717 -0
- osrlib/data/equipment.json +881 -0
- osrlib/data/languages.json +116 -0
- osrlib/data/magic_items.json +9149 -0
- osrlib/data/monsters.json +30752 -0
- osrlib/data/spells.json +6534 -0
- osrlib/data/treasure.json +1507 -0
- osrlib/errors.py +57 -0
- osrlib/messages.py +350 -0
- osrlib/persistence.py +331 -0
- osrlib/py.typed +0 -0
- osrlib/versioning.py +110 -0
- osrlib-1.0.0.dist-info/METADATA +150 -0
- osrlib-1.0.0.dist-info/RECORD +53 -0
- osrlib-1.0.0.dist-info/WHEEL +4 -0
- osrlib-1.0.0.dist-info/licenses/LICENSE +21 -0
- osrlib-1.0.0.dist-info/licenses/LICENSE-OGL.md +97 -0
osrlib/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Old-School Essentials (B/X) rules engine for turn-based dungeon crawlers.
|
|
2
|
+
|
|
3
|
+
osrlib is the rules authority and game-state engine; the game supplies presentation,
|
|
4
|
+
input, and content. The library is headless and sans-I/O: it never renders, prompts,
|
|
5
|
+
sleeps, or touches the network, and all randomness flows through named deterministic
|
|
6
|
+
streams (see [`osrlib.core.rng`][osrlib.core.rng]).
|
|
7
|
+
|
|
8
|
+
Every symbol has exactly one import home: the kernel under `osrlib.core`, the crawl
|
|
9
|
+
framework under `osrlib.crawl`, and the shared services at the top level —
|
|
10
|
+
[`osrlib.data`][osrlib.data] (compiled SRD catalogs), [`osrlib.errors`][osrlib.errors]
|
|
11
|
+
(the typed exception hierarchy), [`osrlib.messages`][osrlib.messages] (message-code
|
|
12
|
+
formatting), [`osrlib.persistence`][osrlib.persistence] (saves and replay), and
|
|
13
|
+
[`osrlib.versioning`][osrlib.versioning] (schema and engine version stamping). The
|
|
14
|
+
package root re-exports nothing.
|
|
15
|
+
|
|
16
|
+
The quickstart below crosses the whole loop — characters, party, adventure, session,
|
|
17
|
+
commands, events, save, and load. Full documentation, including a stepwise version of
|
|
18
|
+
this example: https://mmacy.github.io/osrlib-python/
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from osrlib.core.alignment import Alignment
|
|
22
|
+
from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character
|
|
23
|
+
from osrlib.core.rng import RngStreams
|
|
24
|
+
from osrlib.core.ruleset import Ruleset
|
|
25
|
+
from osrlib.crawl.adventure import Adventure, TownSpec
|
|
26
|
+
from osrlib.crawl.commands import EnterDungeon, MoveParty, SessionMode
|
|
27
|
+
from osrlib.crawl.dungeon import Direction, DungeonSpec, Edge, EdgeKind, LevelSpec
|
|
28
|
+
from osrlib.crawl.party import Party
|
|
29
|
+
from osrlib.crawl.session import GameSession
|
|
30
|
+
from osrlib.messages import format_message
|
|
31
|
+
from osrlib.persistence import load_game, save_game
|
|
32
|
+
|
|
33
|
+
# Roll two 1st-level characters; every random draw comes from a named, seeded stream.
|
|
34
|
+
rules = Ruleset()
|
|
35
|
+
creation = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
|
|
36
|
+
fighter = create_character(name="Hild", class_id="fighter", alignment=Alignment.LAWFUL, ruleset=rules, stream=creation)
|
|
37
|
+
cleric = create_character(name="Osric", class_id="cleric", alignment=Alignment.LAWFUL, ruleset=rules, stream=creation)
|
|
38
|
+
party = Party(members=[fighter.character, cleric.character])
|
|
39
|
+
|
|
40
|
+
# The smallest adventure: a town and a one-corridor dungeon, two cells joined west-east.
|
|
41
|
+
crypt = DungeonSpec(
|
|
42
|
+
id="crypt",
|
|
43
|
+
name="The Old Crypt",
|
|
44
|
+
levels=(LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)}),),
|
|
45
|
+
)
|
|
46
|
+
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
|
|
47
|
+
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
|
|
48
|
+
|
|
49
|
+
# A session starts in town; entering the dungeon switches it to exploring.
|
|
50
|
+
session = GameSession.new(party, adventure, seed=7)
|
|
51
|
+
session.execute(EnterDungeon(dungeon_id="crypt"))
|
|
52
|
+
assert session.mode is SessionMode.EXPLORING
|
|
53
|
+
|
|
54
|
+
# Commands in, events out: every rules resolution is a typed event with a message code.
|
|
55
|
+
result = session.execute(MoveParty(direction=Direction.EAST))
|
|
56
|
+
assert result.accepted
|
|
57
|
+
lines = [format_message(event) for event in result.events]
|
|
58
|
+
assert lines # every event formats to a default English line
|
|
59
|
+
|
|
60
|
+
# The whole session round-trips through JSON: same seed, same commands, same game.
|
|
61
|
+
document = save_game(session)
|
|
62
|
+
restored = load_game(document)
|
|
63
|
+
assert save_game(restored) == document
|
|
64
|
+
```
|
|
65
|
+
"""
|
osrlib/core/__init__.py
ADDED
osrlib/core/abilities.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Ability scores: modifier tables, checks, prime requisites, and the adjustment step.
|
|
2
|
+
|
|
3
|
+
The six modifier tables (and the prime requisite XP table) are compiled from the SRD's
|
|
4
|
+
Ability Scores page into `abilities.json` and load as one frozen
|
|
5
|
+
[`AbilityTables`][osrlib.core.abilities.AbilityTables] model, which carries one accessor
|
|
6
|
+
per SRD column. Tables are stored as score bands exactly as the SRD prints them (`4–5`,
|
|
7
|
+
`13–15`), validated to cover 3–18 contiguously.
|
|
8
|
+
|
|
9
|
+
Scores range 3–18: 3d6 can roll nothing else, the SRD's tables list nothing else, and
|
|
10
|
+
the creation-time adjustment step may not raise a score above 18 nor lower one below 9.
|
|
11
|
+
|
|
12
|
+
Ability checks per the SRD: roll 1d20, equal-or-under the score succeeds, with a
|
|
13
|
+
caller-supplied difficulty modifier (±4 easy/hard) applied to the roll. A natural 1
|
|
14
|
+
always succeeds and a natural 20 always fails — *inverted* from attack rolls, where a
|
|
15
|
+
natural 20 always hits. Open-doors checks are d6 ≤ the STR-derived chance.
|
|
16
|
+
|
|
17
|
+
The adjustment step is pure validation plus atomic application over a rolled score set
|
|
18
|
+
and a chosen class's prime requisites: lower STR/INT/WIS two-for-one into prime
|
|
19
|
+
requisites, floor 9, cap 18, with class-specific restrictions (a thief may not lower
|
|
20
|
+
STR) carried as data on the class definition.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from enum import StrEnum
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
26
|
+
|
|
27
|
+
from osrlib.core.rng import RngStream
|
|
28
|
+
from osrlib.core.validation import Rejection
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ADJUSTMENT_FLOOR",
|
|
32
|
+
"MAX_SCORE",
|
|
33
|
+
"MIN_SCORE",
|
|
34
|
+
"AbilityAdjustment",
|
|
35
|
+
"AbilityCheckResult",
|
|
36
|
+
"AbilityScore",
|
|
37
|
+
"AbilityTables",
|
|
38
|
+
"CharismaRow",
|
|
39
|
+
"ConstitutionRow",
|
|
40
|
+
"DexterityRow",
|
|
41
|
+
"IntelligenceRow",
|
|
42
|
+
"Literacy",
|
|
43
|
+
"OpenDoorsResult",
|
|
44
|
+
"PrimeRequisiteRow",
|
|
45
|
+
"ScoreBand",
|
|
46
|
+
"StrengthRow",
|
|
47
|
+
"WisdomRow",
|
|
48
|
+
"ability_check",
|
|
49
|
+
"apply_adjustment",
|
|
50
|
+
"open_doors_check",
|
|
51
|
+
"validate_adjustment",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
MIN_SCORE = 3
|
|
55
|
+
"""The lowest possible ability score (3d6 minimum; also the tables' floor)."""
|
|
56
|
+
|
|
57
|
+
MAX_SCORE = 18
|
|
58
|
+
"""The highest possible ability score (3d6 maximum; also the adjustment raise cap)."""
|
|
59
|
+
|
|
60
|
+
ADJUSTMENT_FLOOR = 9
|
|
61
|
+
"""No score may be lowered below this value during the adjustment step."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AbilityScore(StrEnum):
|
|
65
|
+
"""The six ability scores.
|
|
66
|
+
|
|
67
|
+
The wire values are lowercase (`"str"`, `"int"`, ...) — they serialize into
|
|
68
|
+
characters and saves; changing them is a `schema_version` bump.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
STR = "str"
|
|
72
|
+
INT = "int"
|
|
73
|
+
WIS = "wis"
|
|
74
|
+
DEX = "dex"
|
|
75
|
+
CON = "con"
|
|
76
|
+
CHA = "cha"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Literacy(StrEnum):
|
|
80
|
+
"""The INT table's literacy column."""
|
|
81
|
+
|
|
82
|
+
ILLITERATE = "illiterate"
|
|
83
|
+
BASIC = "basic"
|
|
84
|
+
LITERATE = "literate"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ScoreBand(BaseModel):
|
|
88
|
+
"""A contiguous score range in a modifier table, as the SRD prints it (`4–5`)."""
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(frozen=True)
|
|
91
|
+
|
|
92
|
+
min_score: int = Field(ge=MIN_SCORE, le=MAX_SCORE)
|
|
93
|
+
max_score: int = Field(ge=MIN_SCORE, le=MAX_SCORE)
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="after")
|
|
96
|
+
def _band_must_be_ordered(self) -> ScoreBand:
|
|
97
|
+
if self.min_score > self.max_score:
|
|
98
|
+
raise ValueError(f"band minimum {self.min_score} exceeds maximum {self.max_score}")
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class StrengthRow(ScoreBand):
|
|
103
|
+
"""One STR band: melee modifier and open-doors chance (X-in-6)."""
|
|
104
|
+
|
|
105
|
+
melee: int
|
|
106
|
+
open_doors: int = Field(ge=0, le=6)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class IntelligenceRow(ScoreBand):
|
|
110
|
+
"""One INT band: additional spoken languages, literacy, and broken speech at INT 3."""
|
|
111
|
+
|
|
112
|
+
additional_languages: int = Field(ge=0)
|
|
113
|
+
literacy: Literacy
|
|
114
|
+
broken_speech: bool = False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class WisdomRow(ScoreBand):
|
|
118
|
+
"""One WIS band: saving throw modifier versus magical effects."""
|
|
119
|
+
|
|
120
|
+
magic_saves: int
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DexterityRow(ScoreBand):
|
|
124
|
+
"""One DEX band: AC modifier, missile attack modifier, and optional-rule initiative modifier."""
|
|
125
|
+
|
|
126
|
+
ac: int
|
|
127
|
+
missile: int
|
|
128
|
+
initiative: int
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ConstitutionRow(ScoreBand):
|
|
132
|
+
"""One CON band: hit point modifier per Hit Die."""
|
|
133
|
+
|
|
134
|
+
hit_points: int
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class CharismaRow(ScoreBand):
|
|
138
|
+
"""One CHA band: NPC reaction modifier, retainer maximum, and retainer loyalty."""
|
|
139
|
+
|
|
140
|
+
npc_reactions: int
|
|
141
|
+
max_retainers: int = Field(ge=0)
|
|
142
|
+
retainer_loyalty: int = Field(ge=0)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PrimeRequisiteRow(ScoreBand):
|
|
146
|
+
"""One prime requisite band: XP modifier percentage for single-prime-requisite classes."""
|
|
147
|
+
|
|
148
|
+
xp_modifier_pct: int
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _validate_coverage(rows: tuple[ScoreBand, ...], table: str) -> None:
|
|
152
|
+
expected = MIN_SCORE
|
|
153
|
+
for row in rows:
|
|
154
|
+
if row.min_score != expected:
|
|
155
|
+
raise ValueError(f"{table} table bands must cover 3-18 contiguously; expected band start {expected}")
|
|
156
|
+
expected = row.max_score + 1
|
|
157
|
+
if expected != MAX_SCORE + 1:
|
|
158
|
+
raise ValueError(f"{table} table bands must end at {MAX_SCORE}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class AbilityTables(BaseModel):
|
|
162
|
+
"""The six ability modifier tables plus the prime requisite XP table.
|
|
163
|
+
|
|
164
|
+
Loaded from `abilities.json` via
|
|
165
|
+
[`load_ability_tables`][osrlib.data.load_ability_tables]. Accessors take a score in
|
|
166
|
+
3–18 and raise stdlib `ValueError` outside that range (programmer misuse).
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
model_config = ConfigDict(frozen=True)
|
|
170
|
+
|
|
171
|
+
strength: tuple[StrengthRow, ...]
|
|
172
|
+
intelligence: tuple[IntelligenceRow, ...]
|
|
173
|
+
wisdom: tuple[WisdomRow, ...]
|
|
174
|
+
dexterity: tuple[DexterityRow, ...]
|
|
175
|
+
constitution: tuple[ConstitutionRow, ...]
|
|
176
|
+
charisma: tuple[CharismaRow, ...]
|
|
177
|
+
prime_requisite: tuple[PrimeRequisiteRow, ...]
|
|
178
|
+
|
|
179
|
+
@model_validator(mode="after")
|
|
180
|
+
def _tables_must_cover_all_scores(self) -> AbilityTables:
|
|
181
|
+
for table in ("strength", "intelligence", "wisdom", "dexterity", "constitution", "charisma", "prime_requisite"):
|
|
182
|
+
_validate_coverage(getattr(self, table), table)
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def _row[RowT: ScoreBand](self, rows: tuple[RowT, ...], score: int) -> RowT:
|
|
186
|
+
if not MIN_SCORE <= score <= MAX_SCORE:
|
|
187
|
+
raise ValueError(f"ability score must be in {MIN_SCORE}-{MAX_SCORE}, got {score}")
|
|
188
|
+
for row in rows:
|
|
189
|
+
if row.min_score <= score <= row.max_score:
|
|
190
|
+
return row
|
|
191
|
+
raise ValueError(f"no band covers score {score}") # unreachable given coverage validation
|
|
192
|
+
|
|
193
|
+
def melee_modifier(self, score: int) -> int:
|
|
194
|
+
"""Return the STR modifier to melee attack and damage rolls."""
|
|
195
|
+
return self._row(self.strength, score).melee
|
|
196
|
+
|
|
197
|
+
def open_doors_chance(self, score: int) -> int:
|
|
198
|
+
"""Return the STR-derived X-in-6 chance to force open a stuck door."""
|
|
199
|
+
return self._row(self.strength, score).open_doors
|
|
200
|
+
|
|
201
|
+
def additional_languages(self, score: int) -> int:
|
|
202
|
+
"""Return the number of additional spoken languages granted by INT."""
|
|
203
|
+
return self._row(self.intelligence, score).additional_languages
|
|
204
|
+
|
|
205
|
+
def literacy(self, score: int) -> Literacy:
|
|
206
|
+
"""Return the INT-derived literacy in the character's native languages."""
|
|
207
|
+
return self._row(self.intelligence, score).literacy
|
|
208
|
+
|
|
209
|
+
def magic_save_modifier(self, score: int) -> int:
|
|
210
|
+
"""Return the WIS modifier to saving throws versus magical effects."""
|
|
211
|
+
return self._row(self.wisdom, score).magic_saves
|
|
212
|
+
|
|
213
|
+
def ac_modifier(self, score: int) -> int:
|
|
214
|
+
"""Return the DEX modifier to AC (a bonus lowers descending AC)."""
|
|
215
|
+
return self._row(self.dexterity, score).ac
|
|
216
|
+
|
|
217
|
+
def missile_modifier(self, score: int) -> int:
|
|
218
|
+
"""Return the DEX modifier to missile attack rolls (not damage)."""
|
|
219
|
+
return self._row(self.dexterity, score).missile
|
|
220
|
+
|
|
221
|
+
def initiative_modifier(self, score: int) -> int:
|
|
222
|
+
"""Return the DEX modifier to individual initiative (optional rule)."""
|
|
223
|
+
return self._row(self.dexterity, score).initiative
|
|
224
|
+
|
|
225
|
+
def hit_point_modifier(self, score: int) -> int:
|
|
226
|
+
"""Return the CON modifier applied per Hit Die rolled (minimum 1 hp per die)."""
|
|
227
|
+
return self._row(self.constitution, score).hit_points
|
|
228
|
+
|
|
229
|
+
def npc_reaction_modifier(self, score: int) -> int:
|
|
230
|
+
"""Return the CHA modifier to NPC reactions."""
|
|
231
|
+
return self._row(self.charisma, score).npc_reactions
|
|
232
|
+
|
|
233
|
+
def max_retainers(self, score: int) -> int:
|
|
234
|
+
"""Return the CHA-derived maximum number of retainers."""
|
|
235
|
+
return self._row(self.charisma, score).max_retainers
|
|
236
|
+
|
|
237
|
+
def retainer_loyalty(self, score: int) -> int:
|
|
238
|
+
"""Return the CHA-derived retainer loyalty rating."""
|
|
239
|
+
return self._row(self.charisma, score).retainer_loyalty
|
|
240
|
+
|
|
241
|
+
def prime_requisite_xp_modifier_pct(self, score: int) -> int:
|
|
242
|
+
"""Return the XP modifier percentage for a single prime requisite at `score`."""
|
|
243
|
+
return self._row(self.prime_requisite, score).xp_modifier_pct
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class AbilityCheckResult(BaseModel):
|
|
247
|
+
"""The outcome of an ability check, with the raw roll kept for display."""
|
|
248
|
+
|
|
249
|
+
model_config = ConfigDict(frozen=True)
|
|
250
|
+
|
|
251
|
+
roll: int
|
|
252
|
+
score: int
|
|
253
|
+
modifier: int
|
|
254
|
+
success: bool
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class OpenDoorsResult(BaseModel):
|
|
258
|
+
"""The outcome of an open-doors check, with the raw roll kept for display."""
|
|
259
|
+
|
|
260
|
+
model_config = ConfigDict(frozen=True)
|
|
261
|
+
|
|
262
|
+
roll: int
|
|
263
|
+
chance: int
|
|
264
|
+
success: bool
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def ability_check(score: int, stream: RngStream, modifier: int = 0) -> AbilityCheckResult:
|
|
268
|
+
"""Roll an ability check: 1d20, equal-or-under the score succeeds.
|
|
269
|
+
|
|
270
|
+
The caller-supplied difficulty modifier is added to the roll (the SRD suggests −4
|
|
271
|
+
for an easy task, +4 for a difficult one). A natural 1 always succeeds and a
|
|
272
|
+
natural 20 always fails — inverted from attack rolls, where a natural 20 always
|
|
273
|
+
hits and a natural 1 always misses.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
score: The ability score to check against, in 3–18.
|
|
277
|
+
stream: The RNG stream to draw from.
|
|
278
|
+
modifier: Difficulty modifier added to the roll; positive makes it harder.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The check outcome, including the raw d20 roll.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
ValueError: If `score` is outside 3–18.
|
|
285
|
+
"""
|
|
286
|
+
if not MIN_SCORE <= score <= MAX_SCORE:
|
|
287
|
+
raise ValueError(f"ability score must be in {MIN_SCORE}-{MAX_SCORE}, got {score}")
|
|
288
|
+
roll = stream.randbelow(20) + 1
|
|
289
|
+
if roll == 1:
|
|
290
|
+
success = True
|
|
291
|
+
elif roll == 20:
|
|
292
|
+
success = False
|
|
293
|
+
else:
|
|
294
|
+
success = roll + modifier <= score
|
|
295
|
+
return AbilityCheckResult(roll=roll, score=score, modifier=modifier, success=success)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def open_doors_check(chance: int, stream: RngStream) -> OpenDoorsResult:
|
|
299
|
+
"""Roll an open-doors check: d6, equal-or-under the X-in-6 chance succeeds.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
chance: The X-in-6 chance, from
|
|
303
|
+
[`AbilityTables.open_doors_chance`][osrlib.core.abilities.AbilityTables.open_doors_chance].
|
|
304
|
+
stream: The RNG stream to draw from.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
The check outcome, including the raw d6 roll.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ValueError: If `chance` is outside 0–6.
|
|
311
|
+
"""
|
|
312
|
+
if not 0 <= chance <= 6:
|
|
313
|
+
raise ValueError(f"open-doors chance must be in 0-6, got {chance}")
|
|
314
|
+
roll = stream.randbelow(6) + 1
|
|
315
|
+
return OpenDoorsResult(roll=roll, chance=chance, success=roll <= chance)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
_LOWERABLE = (AbilityScore.STR, AbilityScore.INT, AbilityScore.WIS)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class AbilityAdjustment(BaseModel):
|
|
322
|
+
"""The creation-time adjustment: even reductions traded two-for-one into prime requisite raises.
|
|
323
|
+
|
|
324
|
+
`lowered` maps abilities to the (positive) amount subtracted; `raised` maps
|
|
325
|
+
abilities to the (positive) amount added. An empty adjustment is a legal no-op.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
model_config = ConfigDict(frozen=True)
|
|
329
|
+
|
|
330
|
+
lowered: dict[AbilityScore, int] = {}
|
|
331
|
+
raised: dict[AbilityScore, int] = {}
|
|
332
|
+
|
|
333
|
+
@model_validator(mode="after")
|
|
334
|
+
def _amounts_must_be_positive(self) -> AbilityAdjustment:
|
|
335
|
+
for name, amounts in (("lowered", self.lowered), ("raised", self.raised)):
|
|
336
|
+
for ability, amount in amounts.items():
|
|
337
|
+
if amount <= 0:
|
|
338
|
+
raise ValueError(f"{name}[{ability}] must be positive, got {amount}")
|
|
339
|
+
return self
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def validate_adjustment(
|
|
343
|
+
scores: dict[AbilityScore, int],
|
|
344
|
+
adjustment: AbilityAdjustment,
|
|
345
|
+
prime_requisites: tuple[AbilityScore, ...],
|
|
346
|
+
may_not_lower: tuple[AbilityScore, ...] = (),
|
|
347
|
+
) -> list[Rejection]:
|
|
348
|
+
"""Validate an adjustment against the SRD's rules, returning structured rejections.
|
|
349
|
+
|
|
350
|
+
The rules, per the SRD's Creating a Character step 3 and the adaptations register's
|
|
351
|
+
interpretations:
|
|
352
|
+
|
|
353
|
+
- Only STR, INT, and WIS may be lowered.
|
|
354
|
+
- A prime requisite of the chosen class may not be lowered, nor may an ability in
|
|
355
|
+
the class's `may_not_lower` restrictions (the thief's STR).
|
|
356
|
+
- Each lowered score drops by an even amount (the two-for-one trade is per-score,
|
|
357
|
+
so an odd reduction would strand half a point).
|
|
358
|
+
- The total raise equals the sum of reductions divided by two, distributed freely
|
|
359
|
+
among the class's prime requisites and nowhere else.
|
|
360
|
+
- No lowered score drops below 9; no raised score rises above 18.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
scores: The rolled scores, all six abilities present.
|
|
364
|
+
adjustment: The proposed adjustment.
|
|
365
|
+
prime_requisites: The chosen class's prime requisites.
|
|
366
|
+
may_not_lower: Class-specific lowering restrictions, from the class definition.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Structured rejections; empty when the adjustment is legal.
|
|
370
|
+
"""
|
|
371
|
+
rejections: list[Rejection] = []
|
|
372
|
+
for ability, amount in adjustment.lowered.items():
|
|
373
|
+
if ability not in _LOWERABLE:
|
|
374
|
+
rejections.append(Rejection(code="creation.adjustment.not_lowerable", params={"ability": ability}))
|
|
375
|
+
if ability in prime_requisites:
|
|
376
|
+
rejections.append(
|
|
377
|
+
Rejection(code="creation.adjustment.prime_requisite_lowered", params={"ability": ability})
|
|
378
|
+
)
|
|
379
|
+
if ability in may_not_lower:
|
|
380
|
+
rejections.append(Rejection(code="creation.adjustment.class_restriction", params={"ability": ability}))
|
|
381
|
+
if amount % 2 != 0:
|
|
382
|
+
rejections.append(
|
|
383
|
+
Rejection(code="creation.adjustment.reduction_not_even", params={"ability": ability, "amount": amount})
|
|
384
|
+
)
|
|
385
|
+
if scores[ability] - amount < ADJUSTMENT_FLOOR:
|
|
386
|
+
rejections.append(
|
|
387
|
+
Rejection(
|
|
388
|
+
code="creation.adjustment.below_floor",
|
|
389
|
+
params={"ability": ability, "score": scores[ability], "amount": amount},
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
for ability, amount in adjustment.raised.items():
|
|
393
|
+
if ability not in prime_requisites:
|
|
394
|
+
rejections.append(
|
|
395
|
+
Rejection(code="creation.adjustment.raise_not_prime_requisite", params={"ability": ability})
|
|
396
|
+
)
|
|
397
|
+
elif scores[ability] + amount > MAX_SCORE:
|
|
398
|
+
rejections.append(
|
|
399
|
+
Rejection(
|
|
400
|
+
code="creation.adjustment.above_cap",
|
|
401
|
+
params={"ability": ability, "score": scores[ability], "amount": amount},
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
total_lowered = sum(adjustment.lowered.values())
|
|
405
|
+
total_raised = sum(adjustment.raised.values())
|
|
406
|
+
if total_raised != total_lowered // 2:
|
|
407
|
+
rejections.append(
|
|
408
|
+
Rejection(
|
|
409
|
+
code="creation.adjustment.points_mismatch",
|
|
410
|
+
params={"points_available": total_lowered // 2, "points_spent": total_raised},
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
return rejections
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def apply_adjustment(
|
|
417
|
+
scores: dict[AbilityScore, int],
|
|
418
|
+
adjustment: AbilityAdjustment,
|
|
419
|
+
prime_requisites: tuple[AbilityScore, ...],
|
|
420
|
+
may_not_lower: tuple[AbilityScore, ...] = (),
|
|
421
|
+
) -> dict[AbilityScore, int]:
|
|
422
|
+
"""Apply a validated adjustment atomically, returning the new score set.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
scores: The rolled scores; not mutated.
|
|
426
|
+
adjustment: The adjustment to apply.
|
|
427
|
+
prime_requisites: The chosen class's prime requisites.
|
|
428
|
+
may_not_lower: Class-specific lowering restrictions.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
A new score dict with reductions and raises applied.
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
ValueError: If the adjustment fails
|
|
435
|
+
[`validate_adjustment`][osrlib.core.abilities.validate_adjustment] —
|
|
436
|
+
applying an illegal adjustment is programmer misuse.
|
|
437
|
+
"""
|
|
438
|
+
rejections = validate_adjustment(scores, adjustment, prime_requisites, may_not_lower)
|
|
439
|
+
if rejections:
|
|
440
|
+
codes = [rejection.code for rejection in rejections]
|
|
441
|
+
raise ValueError(f"illegal ability adjustment: {codes}")
|
|
442
|
+
adjusted = dict(scores)
|
|
443
|
+
for ability, amount in adjustment.lowered.items():
|
|
444
|
+
adjusted[ability] -= amount
|
|
445
|
+
for ability, amount in adjustment.raised.items():
|
|
446
|
+
adjusted[ability] += amount
|
|
447
|
+
return adjusted
|
osrlib/core/alignment.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The three alignments, in a home importable by both characters and monsters.
|
|
2
|
+
|
|
3
|
+
[`Alignment`][osrlib.core.alignment.Alignment] lives in its own module because both
|
|
4
|
+
character and monster data carry alignments, and the generated-data loaders import
|
|
5
|
+
the monster models: a module the loaders import must not import `character`, which
|
|
6
|
+
itself imports the loaders.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Alignment",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Alignment(StrEnum):
|
|
17
|
+
"""The three alignments.
|
|
18
|
+
|
|
19
|
+
The wire values are lowercase — they serialize into characters and saves; changing
|
|
20
|
+
them is a `schema_version` bump.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
LAWFUL = "lawful"
|
|
24
|
+
NEUTRAL = "neutral"
|
|
25
|
+
CHAOTIC = "chaotic"
|