osrlib 2.2.0__tar.gz → 2.3.0__tar.gz

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 (61) hide show
  1. {osrlib-2.2.0 → osrlib-2.3.0}/PKG-INFO +1 -1
  2. {osrlib-2.2.0 → osrlib-2.3.0}/pyproject.toml +1 -1
  3. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/character.py +5 -16
  4. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/combat.py +16 -8
  5. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/events.py +37 -12
  6. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/items.py +22 -0
  7. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/battle.py +1566 -430
  8. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/commands.py +252 -135
  9. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/encounter.py +13 -6
  10. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/events.py +113 -34
  11. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/exploration.py +3 -1
  12. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/session.py +57 -0
  13. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/views.py +172 -22
  14. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/messages.py +15 -2
  15. {osrlib-2.2.0 → osrlib-2.3.0}/LICENSE +0 -0
  16. {osrlib-2.2.0 → osrlib-2.3.0}/LICENSE-OGL.md +0 -0
  17. {osrlib-2.2.0 → osrlib-2.3.0}/README.md +0 -0
  18. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/__init__.py +0 -0
  19. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/__init__.py +0 -0
  20. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/abilities.py +0 -0
  21. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/alignment.py +0 -0
  22. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/classes.py +0 -0
  23. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/clock.py +0 -0
  24. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/creature.py +0 -0
  25. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/dice.py +0 -0
  26. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/effects.py +0 -0
  27. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/monsters.py +0 -0
  28. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/npc.py +0 -0
  29. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/rng.py +0 -0
  30. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/ruleset.py +0 -0
  31. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/spells.py +0 -0
  32. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/tables.py +0 -0
  33. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/treasure.py +0 -0
  34. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/core/validation.py +0 -0
  35. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/__init__.py +0 -0
  36. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/adventure.py +0 -0
  37. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/content_pack.py +0 -0
  38. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/dungeon.py +0 -0
  39. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/gates.py +0 -0
  40. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/interpreter.py +0 -0
  41. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/narrative.py +0 -0
  42. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/party.py +0 -0
  43. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/quests.py +0 -0
  44. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/stocking.py +0 -0
  45. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/crawl/triggers.py +0 -0
  46. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/LICENSE-OGL.md +0 -0
  47. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/__init__.py +0 -0
  48. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/abilities.json +0 -0
  49. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/classes.json +0 -0
  50. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/combat_tables.json +0 -0
  51. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/encounter_tables.json +0 -0
  52. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/equipment.json +0 -0
  53. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/languages.json +0 -0
  54. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/magic_items.json +0 -0
  55. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/monsters.json +0 -0
  56. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/spells.json +0 -0
  57. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/data/treasure.json +0 -0
  58. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/errors.py +0 -0
  59. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/persistence.py +0 -0
  60. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/py.typed +0 -0
  61. {osrlib-2.2.0 → osrlib-2.3.0}/src/osrlib/versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: osrlib
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: B/X (1981 Basic/Expert) tabletop RPG rules engine for turn-based dungeon crawlers
5
5
  Keywords: basic-expert,bx,dungeon-crawler,game-engine,osr,rpg,srd,ttrpg
6
6
  Author: Marsh Macy
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "osrlib"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "B/X (1981 Basic/Expert) tabletop RPG rules engine for turn-based dungeon crawlers"
5
5
  readme = "README.md"
6
6
  # The wheel ships Open Game Content (osrlib/data/*.json and its LICENSE-OGL.md
@@ -492,7 +492,7 @@ class Character(BaseModel):
492
492
  the +1 an ordinary shield gives. A worn item with an armour class bonus that is always
493
493
  active, like a ring of protection, adds to the bonus too.
494
494
  """
495
- from osrlib.core.items import ArmourTemplate, MagicItemInstance, magic_item_template
495
+ from osrlib.core.items import ArmourTemplate, MagicItemInstance, _shield_ac_parts, magic_item_template
496
496
  from osrlib.data import load_equipment
497
497
 
498
498
  base = 10 if ascending else 9
@@ -509,21 +509,10 @@ class Character(BaseModel):
509
509
  elif worn is not None and isinstance(worn.template, ArmourTemplate) and worn.template.ac is not None:
510
510
  printed_ascending = worn.template.ac_ascending
511
511
  base = printed_ascending if ascending and printed_ascending is not None else worn.template.ac
512
- bonus = 0
513
- shield = self.inventory.shield
514
- if isinstance(shield, MagicItemInstance):
515
- template = magic_item_template(shield)
516
- if template.ac_set is not None and template.ac_set_ascending is not None:
517
- # The cursed shield's AC 9 [10] sets the wearer's base outright.
518
- base = template.ac_set_ascending if ascending else template.ac_set
519
- else:
520
- mundane_shield = load_equipment().get("shield")
521
- mundane_bonus = (getattr(mundane_shield, "ac_bonus", None) or 0) if mundane_shield else 0
522
- bonus += mundane_bonus + template.ac_bonus
523
- elif (
524
- shield is not None and isinstance(shield.template, ArmourTemplate) and shield.template.ac_bonus is not None
525
- ):
526
- bonus += shield.template.ac_bonus
512
+ # The cursed shield's AC 9 [10] sets the wearer's base outright.
513
+ shield_base, bonus = _shield_ac_parts(self.inventory.shield, ascending=ascending)
514
+ if shield_base is not None:
515
+ base = shield_base
527
516
  for ring in self.inventory.rings:
528
517
  template = magic_item_template(ring)
529
518
  if template.always_active:
@@ -172,6 +172,7 @@ from osrlib.core.items import (
172
172
  MissileRanges,
173
173
  WeaponQuality,
174
174
  WeaponTemplate,
175
+ _shield_ac_parts,
175
176
  equipped_item_modifiers,
176
177
  magic_item_template,
177
178
  )
@@ -379,8 +380,12 @@ class AttackContext(BaseModel):
379
380
  behind_target: bool = False
380
381
  """Whether the attacker strikes from behind.
381
382
 
382
- The defender's shield doesn't count. With `target_unaware`, a thief attacker gets the
383
- back-stab bonus to hit and to damage, and the attack's
383
+ The defender's shield doesn't count, as the OSE SRD's "AC bonuses from shields are
384
+ negated" says. The attack is made against the armour class the defender would have
385
+ without the shield, so a magic shield loses its enchantment along with the +1 that every
386
+ shield gives. A cursed shield that makes the defender's armour class worse still counts,
387
+ because the rule takes away a shield's bonus and not its penalty. With `target_unaware`,
388
+ a thief attacker gets the back-stab bonus to hit and to damage, and the attack's
384
389
  [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent] has `back_stab` set.
385
390
  """
386
391
 
@@ -395,7 +400,9 @@ class AttackContext(BaseModel):
395
400
  defender_retreating: bool = False
396
401
  """Whether the defender is retreating.
397
402
 
398
- The attacker gets +2 to hit, and the defender's shield doesn't count. A
403
+ The attacker gets +2 to hit, and the defender's shield doesn't count, as it doesn't with
404
+ `behind_target`. The attack is made against the armour class the defender would have
405
+ without the shield, a magic shield's enchantment included, but never a better one. A
399
406
  [`GameSession`][osrlib.crawl.session.GameSession] sets it for you in two cases:
400
407
 
401
408
  - On an attack against a monster group that's fleeing.
@@ -1356,12 +1363,12 @@ def _defender_descending_ac(defender: Any, context: AttackContext, *, missile: b
1356
1363
  ac = getattr(defender, "armour_class", None)
1357
1364
  if ac is None:
1358
1365
  return None
1359
- # Shield AC is ignored against a retreating defender and attacks from behind.
1366
+ # A retreating defender, or one attacked from behind, loses the shield's bonus, a magic
1367
+ # shield's enchantment included. A cursed shield's penalty has no bonus to lose, so it stays.
1360
1368
  if context.defender_retreating or context.behind_target:
1361
1369
  inventory = getattr(defender, "inventory", None)
1362
- shield = getattr(inventory, "shield", None) if inventory is not None else None
1363
- if shield is not None and shield.template.ac_bonus is not None:
1364
- ac += shield.template.ac_bonus
1370
+ _, shield_bonus = _shield_ac_parts(getattr(inventory, "shield", None), ascending=False)
1371
+ ac += max(shield_bonus, 0)
1365
1372
  if context.attacker_large:
1366
1373
  params = _class_ability_params(defender, "defensive_bonus")
1367
1374
  if params is not None:
@@ -1412,7 +1419,8 @@ def attack_roll(
1412
1419
  - The context's `situational_modifier`.
1413
1420
 
1414
1421
  The function adjusts the defender's armour class too. The defender's shield doesn't
1415
- count against an attack from behind, or while the defender retreats. The context's
1422
+ count against an attack from behind, or while the defender retreats, and a magic
1423
+ shield's enchantment doesn't count either. The context's
1416
1424
  `defender_ally_ac_bonus`, an armour class bonus that an ally gives the defender, does
1417
1425
  count.
1418
1426
 
@@ -1139,7 +1139,7 @@ class SpellDisruptedEvent(Event):
1139
1139
 
1140
1140
 
1141
1141
  class DeclarationFizzledEvent(Event):
1142
- """A declared wand, staff, or rod use, or a declared turning, failed before it took effect.
1142
+ """A declared wand, staff, or rod use, a declared turning, or a declared melee attack failed before it took effect.
1143
1143
 
1144
1144
  A [`GameSession`][osrlib.crawl.session.GameSession] reports it during a battle round. At the start of each
1145
1145
  round, every party member declares an action in a
@@ -1150,36 +1150,53 @@ class DeclarationFizzledEvent(Event):
1150
1150
  caused the failure. For example, the group a staff of striking was aimed at moved out of melee reach, or the
1151
1151
  group a wand was aimed at left the battle.
1152
1152
 
1153
- The event's `actor_id` is the member who declared the action, `item_id` is the device, and `reason` is the
1154
- rejection code of the check that failed.
1153
+ A melee attack fizzles when the party's melee step comes and the attack has nothing left to strike, or the
1154
+ member stands in the wrong place. The session judges a melee attack before the round starts, as if every move the
1155
+ round declares had happened. When the attack comes up, it resolves only if its group still has a living monster
1156
+ the member can see to strike, and the member stands in the group's front rank within 5' of it. Something earlier
1157
+ in the round can change either. For example, the party's missiles or spells kill every monster in the group, the
1158
+ monsters win initiative and the group runs before the member's `close`, or a member the session expected to step
1159
+ back can no longer move. Then the attack fizzles with the code `battle.melee.fizzled`. A group that has started to
1160
+ flee is an exception to the position check: a member who stood within 5' of it when the round was accepted still
1161
+ gets the attack, if the group has a monster left to strike and hasn't run out of the battle.
1162
+
1163
+ The event's `actor_id` is the member who declared the action, `item_id` is the device, `group_id` is the group a
1164
+ melee attack was aimed at, and `reason` is the rejection code of the check that failed.
1155
1165
 
1156
1166
  A fizzled declaration spends nothing. The device wasn't used, so it keeps its charge and isn't identified. Tell
1157
- the player the action failed, and that the device still has its charge. When a cast or a scroll read fizzles,
1167
+ the player the action failed, and that the device still has its charge. A fizzled melee attack makes no attack
1168
+ roll, so tell the player why the member didn't swing. When a cast or a scroll read fizzles,
1158
1169
  the session reports a [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent] instead, because the
1159
1170
  spell is lost.
1160
1171
  """
1161
1172
 
1162
- allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.device.fizzled", "magic.turning.fizzled"})
1163
- """The two codes: `magic.device.fizzled` for a wand, staff, or rod use, and `magic.turning.fizzled` for a
1164
- turning."""
1173
+ allowed_codes: ClassVar[frozenset[str]] = frozenset(
1174
+ {"magic.device.fizzled", "magic.turning.fizzled", "battle.melee.fizzled"}
1175
+ )
1176
+ """The three codes: `magic.device.fizzled` for a wand, staff, or rod use, `magic.turning.fizzled` for a
1177
+ turning, and `battle.melee.fizzled` for a melee attack."""
1165
1178
 
1166
1179
  event_type: Literal["declaration_fizzled"] = "declaration_fizzled"
1167
1180
  """The fixed string that identifies this event class in a serialized event."""
1168
1181
 
1169
1182
  code: str
1170
- """Which declaration fizzled: `magic.device.fizzled` or `magic.turning.fizzled`."""
1183
+ """Which declaration fizzled: `magic.device.fizzled`, `magic.turning.fizzled`, or `battle.melee.fizzled`."""
1171
1184
 
1172
1185
  visibility: Visibility = Visibility.PLAYER
1173
1186
  """Player visibility: everyone sees the action fail."""
1174
1187
 
1175
1188
  actor_id: str
1176
- """The entity id of the member who declared the device use or the turning."""
1189
+ """The entity id of the member who declared the device use, the turning, or the melee attack."""
1177
1190
 
1178
1191
  item_id: str | None = None
1179
1192
  """On a `magic.device.fizzled` event, the device's instance id, as the declaration's `item_id` gave it.
1180
1193
 
1181
1194
  The player already used this id, so it reveals nothing about a device that isn't identified. It's `None` on a
1182
- `magic.turning.fizzled` event."""
1195
+ `magic.turning.fizzled` or a `battle.melee.fizzled` event."""
1196
+
1197
+ group_id: str | None = None
1198
+ """On a `battle.melee.fizzled` event, the id of the group the melee attack was aimed at, as the declaration's
1199
+ `target_group_id` gave it. It's `None` on the other codes."""
1183
1200
 
1184
1201
  reason: str
1185
1202
  """Why the declaration fizzled: the code of the first check that failed when the session checked it again.
@@ -1187,9 +1204,17 @@ class DeclarationFizzledEvent(Event):
1187
1204
  Any check the declaration passed at the start of the round can fail, so read the code and don't assume a
1188
1205
  cause. Some codes you may see:
1189
1206
 
1190
- - `combat.attack.out_of_reach`: the group a staff of striking was aimed at moved out of reach.
1207
+ - `combat.attack.out_of_reach`: the group a staff of striking was aimed at moved out of reach, or the member
1208
+ who declared a melee attack is farther than 5' from its group when the attack comes up. A melee attack gets
1209
+ it too when its group ran out of the battle, wherever the member stood.
1210
+ - `battle.declaration.not_in_front_rank`: the member who declared a melee attack is within 5' of its group
1211
+ but not in its front rank when the attack comes up. A melee attack gets this code only when the member is
1212
+ within 5', and `combat.attack.out_of_reach` otherwise.
1191
1213
  - `battle.declaration.unknown_group`: the group a wand was aimed at left the battle.
1192
- - `battle.declaration.no_target`: no living monster is left in the battle for a turning.
1214
+ - `battle.declaration.no_target`: no living monster is left in the battle for a turning, or no living monster
1215
+ the member can see is left in the group a melee attack was aimed at, for example because the party's missiles
1216
+ or spells killed them earlier in the round. A melee attack gets this code wherever the member stands, whether
1217
+ or not the member closed, because the session checks for a target before it checks the member's position.
1193
1218
 
1194
1219
  Match on it as you match on the code of a [`Rejection`][osrlib.core.validation.Rejection].
1195
1220
  [The rejection code reference][rejection-codes] explains each code.
@@ -2254,6 +2254,28 @@ def magic_item_template(instance: MagicItemInstance) -> MagicItemTemplate:
2254
2254
  return load_magic_items().get(instance.template_id)
2255
2255
 
2256
2256
 
2257
+ def _shield_ac_parts(shield: AnyInstance | None, *, ascending: bool) -> tuple[int | None, int]:
2258
+ """Return the armour class a shield sets, or None, and the bonus the shield adds to armour class.
2259
+
2260
+ A mundane shield adds its +1. A magic shield adds its enchantment on top of the +1 an ordinary
2261
+ shield gives, so a cursed shield's negative enchantment can make the sum a penalty. The cursed
2262
+ shield that prints AC 9 [10] sets the wearer's armour class outright and adds nothing.
2263
+ `Character._armour_parts` and the attack roll both read a shield here, so they agree on what it
2264
+ gives.
2265
+ """
2266
+ from osrlib.data import load_equipment
2267
+
2268
+ if isinstance(shield, MagicItemInstance):
2269
+ template = magic_item_template(shield)
2270
+ if template.ac_set is not None and template.ac_set_ascending is not None:
2271
+ return (template.ac_set_ascending if ascending else template.ac_set), 0
2272
+ mundane = load_equipment().get("shield")
2273
+ return None, (getattr(mundane, "ac_bonus", None) or 0) + template.ac_bonus
2274
+ if shield is not None and isinstance(shield.template, ArmourTemplate) and shield.template.ac_bonus is not None:
2275
+ return None, shield.template.ac_bonus
2276
+ return None, 0
2277
+
2278
+
2257
2279
  def equipped_item_modifiers(inventory: Inventory) -> list[ModifierSpec]:
2258
2280
  """Return the stat modifiers a character's equipped magic items grant.
2259
2281