osrlib 2.1.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.1.0 → osrlib-2.3.0}/PKG-INFO +1 -1
  2. {osrlib-2.1.0 → osrlib-2.3.0}/pyproject.toml +1 -1
  3. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/character.py +5 -16
  4. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/combat.py +29 -14
  5. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/events.py +47 -12
  6. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/items.py +22 -0
  7. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/battle.py +1600 -416
  8. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/commands.py +396 -136
  9. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/encounter.py +13 -6
  10. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/events.py +145 -34
  11. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/exploration.py +3 -1
  12. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/party.py +17 -11
  13. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/session.py +172 -2
  14. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/views.py +172 -22
  15. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/messages.py +27 -4
  16. {osrlib-2.1.0 → osrlib-2.3.0}/LICENSE +0 -0
  17. {osrlib-2.1.0 → osrlib-2.3.0}/LICENSE-OGL.md +0 -0
  18. {osrlib-2.1.0 → osrlib-2.3.0}/README.md +0 -0
  19. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/__init__.py +0 -0
  20. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/__init__.py +0 -0
  21. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/abilities.py +0 -0
  22. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/alignment.py +0 -0
  23. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/classes.py +0 -0
  24. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/clock.py +0 -0
  25. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/creature.py +0 -0
  26. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/dice.py +0 -0
  27. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/effects.py +0 -0
  28. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/monsters.py +0 -0
  29. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/npc.py +0 -0
  30. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/rng.py +0 -0
  31. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/ruleset.py +0 -0
  32. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/spells.py +0 -0
  33. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/tables.py +0 -0
  34. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/treasure.py +0 -0
  35. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/core/validation.py +0 -0
  36. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/__init__.py +0 -0
  37. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/adventure.py +0 -0
  38. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/content_pack.py +0 -0
  39. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/dungeon.py +0 -0
  40. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/gates.py +0 -0
  41. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/interpreter.py +0 -0
  42. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/narrative.py +0 -0
  43. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/quests.py +0 -0
  44. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/stocking.py +0 -0
  45. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/crawl/triggers.py +0 -0
  46. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/LICENSE-OGL.md +0 -0
  47. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/__init__.py +0 -0
  48. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/abilities.json +0 -0
  49. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/classes.json +0 -0
  50. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/combat_tables.json +0 -0
  51. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/encounter_tables.json +0 -0
  52. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/equipment.json +0 -0
  53. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/languages.json +0 -0
  54. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/magic_items.json +0 -0
  55. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/monsters.json +0 -0
  56. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/spells.json +0 -0
  57. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/data/treasure.json +0 -0
  58. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/errors.py +0 -0
  59. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/persistence.py +0 -0
  60. {osrlib-2.1.0 → osrlib-2.3.0}/src/osrlib/py.typed +0 -0
  61. {osrlib-2.1.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.1.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.1.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,20 +380,29 @@ 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.
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
389
+ [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent] has `back_stab` set.
384
390
  """
385
391
 
386
392
  target_unaware: bool = False
387
393
  """Whether the defender is unaware of the attack.
388
394
 
389
- With `behind_target`, a thief attacker gets the back-stab bonus to hit and to damage.
395
+ With `behind_target`, a thief attacker gets the back-stab bonus to hit and to damage, and
396
+ the attack's [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent] has `back_stab`
397
+ set.
390
398
  """
391
399
 
392
400
  defender_retreating: bool = False
393
401
  """Whether the defender is retreating.
394
402
 
395
- 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
396
406
  [`GameSession`][osrlib.crawl.session.GameSession] sets it for you in two cases:
397
407
 
398
408
  - On an attack against a monster group that's fleeing.
@@ -1353,12 +1363,12 @@ def _defender_descending_ac(defender: Any, context: AttackContext, *, missile: b
1353
1363
  ac = getattr(defender, "armour_class", None)
1354
1364
  if ac is None:
1355
1365
  return None
1356
- # 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.
1357
1368
  if context.defender_retreating or context.behind_target:
1358
1369
  inventory = getattr(defender, "inventory", None)
1359
- shield = getattr(inventory, "shield", None) if inventory is not None else None
1360
- if shield is not None and shield.template.ac_bonus is not None:
1361
- ac += shield.template.ac_bonus
1370
+ _, shield_bonus = _shield_ac_parts(getattr(inventory, "shield", None), ascending=False)
1371
+ ac += max(shield_bonus, 0)
1362
1372
  if context.attacker_large:
1363
1373
  params = _class_ability_params(defender, "defensive_bonus")
1364
1374
  if params is not None:
@@ -1399,7 +1409,8 @@ def attack_roll(
1399
1409
  modifier from the context's `distance_feet`, and, for a halfling attacker, the
1400
1410
  halfling's missile bonus.
1401
1411
  - A thief's back-stab bonus, when the context sets both `behind_target` and
1402
- `target_unaware`.
1412
+ `target_unaware`. The event's `back_stab` field is then true, on an automatic hit
1413
+ too.
1403
1414
  - +2 against a retreating defender.
1404
1415
  - A magic weapon's attack bonus, or its bonus against the kind of monster it's made
1405
1416
  against.
@@ -1408,7 +1419,8 @@ def attack_roll(
1408
1419
  - The context's `situational_modifier`.
1409
1420
 
1410
1421
  The function adjusts the defender's armour class too. The defender's shield doesn't
1411
- 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
1412
1424
  `defender_ally_ac_bonus`, an armour class bonus that an ally gives the defender, does
1413
1425
  count.
1414
1426
 
@@ -1478,12 +1490,16 @@ def attack_roll(
1478
1490
  name = _attack_name(attack)
1479
1491
  missile = _is_missile_use(attack, context) or (isinstance(attack, MonsterAttack) and context.monster_missile)
1480
1492
  helpless = not missile and any(has_condition(defender, condition) for condition in _HELPLESS)
1493
+ back_stab = (
1494
+ _class_ability_params(attacker, "back_stab") if context.behind_target and context.target_unaware else None
1495
+ )
1481
1496
  if helpless or getattr(defender, "armour_class", 0) is None:
1482
1497
  event = AttackRolledEvent(
1483
1498
  code="combat.attack.auto_hit",
1484
1499
  attacker_id=attacker_id,
1485
1500
  defender_id=defender_id,
1486
1501
  attack_name=name,
1502
+ back_stab=back_stab is not None,
1487
1503
  )
1488
1504
  return AttackRollResult(hit=True, auto=True, events=(event,))
1489
1505
 
@@ -1497,10 +1513,8 @@ def attack_roll(
1497
1513
  modifier += int(halfling.get("bonus", 0))
1498
1514
  else:
1499
1515
  modifier += melee_modifier_for(attacker)
1500
- if context.behind_target and context.target_unaware:
1501
- back_stab = _class_ability_params(attacker, "back_stab")
1502
- if back_stab is not None:
1503
- modifier += int(back_stab.get("attack_bonus", 0))
1516
+ if back_stab is not None:
1517
+ modifier += int(back_stab.get("attack_bonus", 0))
1504
1518
  if context.defender_retreating:
1505
1519
  modifier += 2
1506
1520
  # An enchanted arm's bonus (versus-clauses swapping in their alternate).
@@ -1548,6 +1562,7 @@ def attack_roll(
1548
1562
  required=required,
1549
1563
  defender_ac=ac,
1550
1564
  natural=natural_override,
1565
+ back_stab=back_stab is not None,
1551
1566
  )
1552
1567
  return AttackRollResult(
1553
1568
  hit=hit,
@@ -340,6 +340,16 @@ class AttackRolledEvent(Event):
340
340
  A natural 20 always hits and a natural 1 always misses. This field is set only when that rule changed the
341
341
  outcome: a 20 whose total was below `required`, or a 1 whose total reached it."""
342
342
 
343
+ back_stab: bool = False
344
+ """True when the attack was a thief's back-stab, so your game can say so.
345
+
346
+ [`attack_roll`][osrlib.core.combat.attack_roll] sets it when the attack's
347
+ [`AttackContext`][osrlib.core.combat.AttackContext] sets both `behind_target` and `target_unaware`, and the
348
+ attacker's class has the `back_stab` ability. The back-stab's bonus to hit is part of `modifier`, and the damage
349
+ that follows is doubled. It's true on an automatic hit too, because the damage still doubles. A
350
+ [`GameSession`][osrlib.crawl.session.GameSession] makes that attack for a member who declares
351
+ [`BattleDeclaration.back_stab`][osrlib.crawl.commands.BattleDeclaration.back_stab]."""
352
+
343
353
 
344
354
  class DamageDealtEvent(Event):
345
355
  """Damage dealt to a creature.
@@ -1129,7 +1139,7 @@ class SpellDisruptedEvent(Event):
1129
1139
 
1130
1140
 
1131
1141
  class DeclarationFizzledEvent(Event):
1132
- """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.
1133
1143
 
1134
1144
  A [`GameSession`][osrlib.crawl.session.GameSession] reports it during a battle round. At the start of each
1135
1145
  round, every party member declares an action in a
@@ -1140,36 +1150,53 @@ class DeclarationFizzledEvent(Event):
1140
1150
  caused the failure. For example, the group a staff of striking was aimed at moved out of melee reach, or the
1141
1151
  group a wand was aimed at left the battle.
1142
1152
 
1143
- The event's `actor_id` is the member who declared the action, `item_id` is the device, and `reason` is the
1144
- 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.
1145
1165
 
1146
1166
  A fizzled declaration spends nothing. The device wasn't used, so it keeps its charge and isn't identified. Tell
1147
- 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,
1148
1169
  the session reports a [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent] instead, because the
1149
1170
  spell is lost.
1150
1171
  """
1151
1172
 
1152
- allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.device.fizzled", "magic.turning.fizzled"})
1153
- """The two codes: `magic.device.fizzled` for a wand, staff, or rod use, and `magic.turning.fizzled` for a
1154
- 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."""
1155
1178
 
1156
1179
  event_type: Literal["declaration_fizzled"] = "declaration_fizzled"
1157
1180
  """The fixed string that identifies this event class in a serialized event."""
1158
1181
 
1159
1182
  code: str
1160
- """Which declaration fizzled: `magic.device.fizzled` or `magic.turning.fizzled`."""
1183
+ """Which declaration fizzled: `magic.device.fizzled`, `magic.turning.fizzled`, or `battle.melee.fizzled`."""
1161
1184
 
1162
1185
  visibility: Visibility = Visibility.PLAYER
1163
1186
  """Player visibility: everyone sees the action fail."""
1164
1187
 
1165
1188
  actor_id: str
1166
- """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."""
1167
1190
 
1168
1191
  item_id: str | None = None
1169
1192
  """On a `magic.device.fizzled` event, the device's instance id, as the declaration's `item_id` gave it.
1170
1193
 
1171
1194
  The player already used this id, so it reveals nothing about a device that isn't identified. It's `None` on a
1172
- `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."""
1173
1200
 
1174
1201
  reason: str
1175
1202
  """Why the declaration fizzled: the code of the first check that failed when the session checked it again.
@@ -1177,9 +1204,17 @@ class DeclarationFizzledEvent(Event):
1177
1204
  Any check the declaration passed at the start of the round can fail, so read the code and don't assume a
1178
1205
  cause. Some codes you may see:
1179
1206
 
1180
- - `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.
1181
1213
  - `battle.declaration.unknown_group`: the group a wand was aimed at left the battle.
1182
- - `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.
1183
1218
 
1184
1219
  Match on it as you match on the code of a [`Rejection`][osrlib.core.validation.Rejection].
1185
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