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.
Files changed (53) hide show
  1. osrlib/__init__.py +65 -0
  2. osrlib/core/__init__.py +5 -0
  3. osrlib/core/abilities.py +447 -0
  4. osrlib/core/alignment.py +25 -0
  5. osrlib/core/character.py +768 -0
  6. osrlib/core/classes.py +827 -0
  7. osrlib/core/clock.py +129 -0
  8. osrlib/core/combat.py +2166 -0
  9. osrlib/core/dice.py +167 -0
  10. osrlib/core/effects.py +918 -0
  11. osrlib/core/events.py +781 -0
  12. osrlib/core/items.py +1724 -0
  13. osrlib/core/monsters.py +620 -0
  14. osrlib/core/npc.py +326 -0
  15. osrlib/core/rng.py +287 -0
  16. osrlib/core/ruleset.py +135 -0
  17. osrlib/core/spells.py +2292 -0
  18. osrlib/core/tables.py +733 -0
  19. osrlib/core/treasure.py +969 -0
  20. osrlib/core/validation.py +46 -0
  21. osrlib/crawl/__init__.py +9 -0
  22. osrlib/crawl/adventure.py +172 -0
  23. osrlib/crawl/battle.py +2053 -0
  24. osrlib/crawl/commands.py +1699 -0
  25. osrlib/crawl/dungeon.py +725 -0
  26. osrlib/crawl/encounter.py +774 -0
  27. osrlib/crawl/events.py +752 -0
  28. osrlib/crawl/exploration.py +2903 -0
  29. osrlib/crawl/party.py +96 -0
  30. osrlib/crawl/session.py +982 -0
  31. osrlib/crawl/views.py +418 -0
  32. osrlib/data/LICENSE-OGL.md +99 -0
  33. osrlib/data/__init__.py +283 -0
  34. osrlib/data/abilities.json +307 -0
  35. osrlib/data/classes.json +2827 -0
  36. osrlib/data/combat_tables.json +734 -0
  37. osrlib/data/encounter_tables.json +1717 -0
  38. osrlib/data/equipment.json +881 -0
  39. osrlib/data/languages.json +116 -0
  40. osrlib/data/magic_items.json +9149 -0
  41. osrlib/data/monsters.json +30752 -0
  42. osrlib/data/spells.json +6534 -0
  43. osrlib/data/treasure.json +1507 -0
  44. osrlib/errors.py +57 -0
  45. osrlib/messages.py +350 -0
  46. osrlib/persistence.py +331 -0
  47. osrlib/py.typed +0 -0
  48. osrlib/versioning.py +110 -0
  49. osrlib-1.0.0.dist-info/METADATA +150 -0
  50. osrlib-1.0.0.dist-info/RECORD +53 -0
  51. osrlib-1.0.0.dist-info/WHEEL +4 -0
  52. osrlib-1.0.0.dist-info/licenses/LICENSE +21 -0
  53. 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
+ """
@@ -0,0 +1,5 @@
1
+ """The rules kernel: pure mechanics with no game loop.
2
+
3
+ Kernel modules never import from `osrlib.crawl` (the framework layer) and are usable
4
+ à la carte — a game that wants only the math can call them directly.
5
+ """
@@ -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
@@ -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"