osrlib 2.1.0__tar.gz → 2.2.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.2.0}/PKG-INFO +1 -1
  2. {osrlib-2.1.0 → osrlib-2.2.0}/pyproject.toml +1 -1
  3. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/combat.py +14 -7
  4. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/events.py +10 -0
  5. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/battle.py +49 -1
  6. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/commands.py +151 -8
  7. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/events.py +32 -0
  8. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/party.py +17 -11
  9. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/session.py +115 -2
  10. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/messages.py +12 -2
  11. {osrlib-2.1.0 → osrlib-2.2.0}/LICENSE +0 -0
  12. {osrlib-2.1.0 → osrlib-2.2.0}/LICENSE-OGL.md +0 -0
  13. {osrlib-2.1.0 → osrlib-2.2.0}/README.md +0 -0
  14. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/__init__.py +0 -0
  15. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/__init__.py +0 -0
  16. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/abilities.py +0 -0
  17. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/alignment.py +0 -0
  18. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/character.py +0 -0
  19. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/classes.py +0 -0
  20. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/clock.py +0 -0
  21. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/creature.py +0 -0
  22. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/dice.py +0 -0
  23. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/effects.py +0 -0
  24. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/items.py +0 -0
  25. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/monsters.py +0 -0
  26. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/npc.py +0 -0
  27. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/rng.py +0 -0
  28. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/ruleset.py +0 -0
  29. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/spells.py +0 -0
  30. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/tables.py +0 -0
  31. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/treasure.py +0 -0
  32. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/validation.py +0 -0
  33. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/__init__.py +0 -0
  34. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/adventure.py +0 -0
  35. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/content_pack.py +0 -0
  36. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/dungeon.py +0 -0
  37. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/encounter.py +0 -0
  38. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/exploration.py +0 -0
  39. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/gates.py +0 -0
  40. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/interpreter.py +0 -0
  41. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/narrative.py +0 -0
  42. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/quests.py +0 -0
  43. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/stocking.py +0 -0
  44. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/triggers.py +0 -0
  45. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/views.py +0 -0
  46. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/LICENSE-OGL.md +0 -0
  47. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/__init__.py +0 -0
  48. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/abilities.json +0 -0
  49. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/classes.json +0 -0
  50. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/combat_tables.json +0 -0
  51. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/encounter_tables.json +0 -0
  52. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/equipment.json +0 -0
  53. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/languages.json +0 -0
  54. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/magic_items.json +0 -0
  55. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/monsters.json +0 -0
  56. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/spells.json +0 -0
  57. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/treasure.json +0 -0
  58. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/errors.py +0 -0
  59. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/persistence.py +0 -0
  60. {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/py.typed +0 -0
  61. {osrlib-2.1.0 → osrlib-2.2.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.2.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.2.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
@@ -380,13 +380,16 @@ class AttackContext(BaseModel):
380
380
  """Whether the attacker strikes from behind.
381
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
+ back-stab bonus to hit and to damage, and the attack's
384
+ [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent] has `back_stab` set.
384
385
  """
385
386
 
386
387
  target_unaware: bool = False
387
388
  """Whether the defender is unaware of the attack.
388
389
 
389
- With `behind_target`, a thief attacker gets the back-stab bonus to hit and to damage.
390
+ With `behind_target`, a thief attacker gets the back-stab bonus to hit and to damage, and
391
+ the attack's [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent] has `back_stab`
392
+ set.
390
393
  """
391
394
 
392
395
  defender_retreating: bool = False
@@ -1399,7 +1402,8 @@ def attack_roll(
1399
1402
  modifier from the context's `distance_feet`, and, for a halfling attacker, the
1400
1403
  halfling's missile bonus.
1401
1404
  - A thief's back-stab bonus, when the context sets both `behind_target` and
1402
- `target_unaware`.
1405
+ `target_unaware`. The event's `back_stab` field is then true, on an automatic hit
1406
+ too.
1403
1407
  - +2 against a retreating defender.
1404
1408
  - A magic weapon's attack bonus, or its bonus against the kind of monster it's made
1405
1409
  against.
@@ -1478,12 +1482,16 @@ def attack_roll(
1478
1482
  name = _attack_name(attack)
1479
1483
  missile = _is_missile_use(attack, context) or (isinstance(attack, MonsterAttack) and context.monster_missile)
1480
1484
  helpless = not missile and any(has_condition(defender, condition) for condition in _HELPLESS)
1485
+ back_stab = (
1486
+ _class_ability_params(attacker, "back_stab") if context.behind_target and context.target_unaware else None
1487
+ )
1481
1488
  if helpless or getattr(defender, "armour_class", 0) is None:
1482
1489
  event = AttackRolledEvent(
1483
1490
  code="combat.attack.auto_hit",
1484
1491
  attacker_id=attacker_id,
1485
1492
  defender_id=defender_id,
1486
1493
  attack_name=name,
1494
+ back_stab=back_stab is not None,
1487
1495
  )
1488
1496
  return AttackRollResult(hit=True, auto=True, events=(event,))
1489
1497
 
@@ -1497,10 +1505,8 @@ def attack_roll(
1497
1505
  modifier += int(halfling.get("bonus", 0))
1498
1506
  else:
1499
1507
  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))
1508
+ if back_stab is not None:
1509
+ modifier += int(back_stab.get("attack_bonus", 0))
1504
1510
  if context.defender_retreating:
1505
1511
  modifier += 2
1506
1512
  # An enchanted arm's bonus (versus-clauses swapping in their alternate).
@@ -1548,6 +1554,7 @@ def attack_roll(
1548
1554
  required=required,
1549
1555
  defender_ac=ac,
1550
1556
  natural=natural_override,
1557
+ back_stab=back_stab is not None,
1551
1558
  )
1552
1559
  return AttackRollResult(
1553
1560
  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.
@@ -1784,6 +1784,33 @@ def _projected_front_rank(session, plan: Sequence[_Withdrawal]) -> set[str]:
1784
1784
  return set(order if width is None else order[:width])
1785
1785
 
1786
1786
 
1787
+ def _back_stab_rejection(session, declaration: BattleDeclaration, member) -> Rejection | None:
1788
+ """Return the first back-stab check a declaration fails before its action's own checks, or `None`.
1789
+
1790
+ A declaration without `back_stab` passes. Otherwise the checks run in this order, and each
1791
+ rejection has the member's id as its `character` param:
1792
+
1793
+ 1. `battle.declaration.back_stab_not_attack`: the action isn't `attack`.
1794
+ 2. `battle.declaration.back_stab_no_ability`: the member's class has no `back_stab` ability.
1795
+ 3. `battle.declaration.back_stab_target_aware`: the monsters aren't held by the party's surprise,
1796
+ so [`BattleState.monsters_hold_rounds`][osrlib.crawl.battle.BattleState.monsters_hold_rounds]
1797
+ is zero.
1798
+
1799
+ The pre-phase reads `monsters_hold_rounds` before the monster block of the round spends it, so a
1800
+ back-stab accepted in the free round stays a back-stab for every attack roll of that round.
1801
+ """
1802
+ if not declaration.back_stab:
1803
+ return None
1804
+ params = {"character": member.id}
1805
+ if declaration.action != "attack":
1806
+ return Rejection(code="battle.declaration.back_stab_not_attack", params=params)
1807
+ if not any(ability.tag == "back_stab" for ability in member.definition.abilities):
1808
+ return Rejection(code="battle.declaration.back_stab_no_ability", params=params)
1809
+ if session.battle.monsters_hold_rounds <= 0:
1810
+ return Rejection(code="battle.declaration.back_stab_target_aware", params=params)
1811
+ return None
1812
+
1813
+
1787
1814
  def _validate_declaration(
1788
1815
  session, declaration: BattleDeclaration, member, *, front_rank: set[str] | None = None
1789
1816
  ) -> list[Rejection]:
@@ -1809,8 +1836,16 @@ def _validate_declaration(
1809
1836
  with no such monster left gets `battle.declaration.no_target`, with the member's id as the
1810
1837
  `character` param. Only the magic phase's second run can meet that. The check doesn't ask
1811
1838
  whether any monster is undead, so a refusal never tells the player which monsters are undead.
1839
+
1840
+ A declaration that sets `back_stab` gets the back-stab checks of `_back_stab_rejection` before
1841
+ any other check. On an attack, `battle.declaration.back_stab_not_melee` comes after the group and
1842
+ weapon checks and before the melee checks, as
1843
+ [`BattleDeclaration.back_stab`][osrlib.crawl.commands.BattleDeclaration.back_stab] gives the order.
1812
1844
  """
1813
1845
  state = session.battle
1846
+ refused = _back_stab_rejection(session, declaration, member)
1847
+ if refused is not None:
1848
+ return [refused]
1814
1849
  if declaration.action == "hold":
1815
1850
  return []
1816
1851
  if declaration.action == "move":
@@ -1836,6 +1871,8 @@ def _validate_declaration(
1836
1871
  if declaration.weapon_id is not None and weapon is None:
1837
1872
  return [Rejection(code="battle.declaration.weapon_not_wielded", params={"item": declaration.weapon_id})]
1838
1873
  missile = _is_missile_declaration(weapon, group.distance_feet)
1874
+ if declaration.back_stab and missile:
1875
+ return [Rejection(code="battle.declaration.back_stab_not_melee", params={"character": member.id})]
1839
1876
  if not missile:
1840
1877
  width = _formation_width(session)
1841
1878
  if front_rank is None:
@@ -2303,7 +2340,15 @@ def _handle_resolve_battle_round(session, command: ResolveBattleRound) -> tuple[
2303
2340
  rejections.extend(_validate_declaration(session, declaration, member, front_rank=front_rank))
2304
2341
  if declaration.gaze_defense == "mirror" and member.inventory.carried_item(_MIRROR_ITEM_ID) is None:
2305
2342
  rejections.append(Rejection(code="battle.declaration.no_mirror", params={"character": member.id}))
2306
- rejections.extend(withdrawal_rejections)
2343
+ # A move that sets `back_stab` gets `back_stab_not_attack` in place of its own withdrawal checks.
2344
+ back_stabbers = {
2345
+ member.id
2346
+ for member, declaration in by_member.values()
2347
+ if declaration.back_stab and declaration.action != "attack"
2348
+ }
2349
+ rejections.extend(
2350
+ rejection for rejection in withdrawal_rejections if rejection.params.get("character") not in back_stabbers
2351
+ )
2307
2352
  if rejections:
2308
2353
  # The session rejects the whole command and lists every rejection. Partial
2309
2354
  # acceptance would complicate replay (see the adaptations register).
@@ -2616,6 +2661,9 @@ def _resolve_party_attack(session, member, declaration, fired, fire_damaged) ->
2616
2661
  situational_modifier=_gaze_stance_modifier(member, declaration),
2617
2662
  fired_last_round=member.id in state.fired_last_round,
2618
2663
  defender_retreating=group.fleeing,
2664
+ # An accepted back-stab stays one for every swing of the round, after the held turn too.
2665
+ behind_target=declaration.back_stab,
2666
+ target_unaware=declaration.back_stab,
2619
2667
  )
2620
2668
  result = resolve_attack(
2621
2669
  member,
@@ -33,7 +33,7 @@ an item or spawning monsters.
33
33
  Game logic issues referee commands, not the players. The issuer can be a
34
34
  [`Listener`][osrlib.crawl.session.Listener] you write, the interpreter that runs an
35
35
  adventure's triggers, or an LLM referee. Referee commands grant and take away items and coins, award
36
- XP, add party members, set flags, spawn monsters, end encounters, move the clock, and record
36
+ XP, add and remove party members, set flags, spawn monsters, end encounters, move the clock, and record
37
37
  quest progress. Each one's summary starts with "Referee:". The session logs and replays them
38
38
  like any other command. Most referee commands are legal in every mode. These are the
39
39
  exceptions:
@@ -45,7 +45,8 @@ exceptions:
45
45
  `victory`, for the same reason.
46
46
  - [`EndEncounter`][osrlib.crawl.commands.EndEncounter] is legal only in `encounter` and
47
47
  `battle`, because it closes the open encounter.
48
- - [`AddMember`][osrlib.crawl.commands.AddMember] is legal only in `town` and `exploring`.
48
+ - [`AddMember`][osrlib.crawl.commands.AddMember] and
49
+ [`RemoveMember`][osrlib.crawl.commands.RemoveMember] are legal only in `town` and `exploring`.
49
50
 
50
51
  Each command class's docstring states its contract in three sections. `Modes:` lists the session
51
52
  modes that accept it, `Rejections:` lists the codes it can be refused with, and `Events:`
@@ -101,6 +102,7 @@ The commands, grouped by what the party is doing:
101
102
  [`RemoveItem`][osrlib.crawl.commands.RemoveItem],
102
103
  [`RemoveCoins`][osrlib.crawl.commands.RemoveCoins],
103
104
  [`AddMember`][osrlib.crawl.commands.AddMember],
105
+ [`RemoveMember`][osrlib.crawl.commands.RemoveMember],
104
106
  [`AwardXP`][osrlib.crawl.commands.AwardXP],
105
107
  [`IdentifyItem`][osrlib.crawl.commands.IdentifyItem],
106
108
  [`SetFlag`][osrlib.crawl.commands.SetFlag],
@@ -233,6 +235,7 @@ __all__ = [
233
235
  "RecordNote",
234
236
  "RemoveCoins",
235
237
  "RemoveItem",
238
+ "RemoveMember",
236
239
  "RemoveTreasureTrap",
237
240
  "ReorderParty",
238
241
  "ResolveBattleRound",
@@ -288,6 +291,8 @@ class SessionMode(StrEnum):
288
291
  restart play.
289
292
  - [`EndEncounter`][osrlib.crawl.commands.EndEncounter] is illegal in both, because it
290
293
  needs an open encounter.
294
+ - [`RemoveMember`][osrlib.crawl.commands.RemoveMember] is illegal in both, because the
295
+ party changes only between encounters in a game that is still going.
291
296
 
292
297
  No play command leaves a terminal mode. A referee's `PlaceParty` is the only way out of
293
298
  `game_over`, and no command leaves `victory`.
@@ -389,8 +394,9 @@ class Command(BaseModel):
389
394
  [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters],
390
395
  [`SpawnNpcParty`][osrlib.crawl.commands.SpawnNpcParty],
391
396
  [`PlaceParty`][osrlib.crawl.commands.PlaceParty],
392
- [`EndEncounter`][osrlib.crawl.commands.EndEncounter], and
393
- [`AddMember`][osrlib.crawl.commands.AddMember]. Each command's `Modes:` section lists
397
+ [`EndEncounter`][osrlib.crawl.commands.EndEncounter],
398
+ [`AddMember`][osrlib.crawl.commands.AddMember], and
399
+ [`RemoveMember`][osrlib.crawl.commands.RemoveMember]. Each command's `Modes:` section lists
394
400
  its modes."""
395
401
 
396
402
  @classmethod
@@ -2473,9 +2479,10 @@ class BattleDeclaration(BaseModel):
2473
2479
 
2474
2480
  `action` decides which other fields apply. Leave the others at their defaults.
2475
2481
 
2476
- - `attack` uses `target_group_id` and, optionally, `weapon_id`. With no `weapon_id`, the
2477
- member attacks with the wielded weapon that suits the attack, or with bare hands when
2478
- they wield none.
2482
+ - `attack` uses `target_group_id` and, optionally, `weapon_id` and `back_stab`. With no
2483
+ `weapon_id`, the member attacks with the wielded weapon that suits the attack, or with
2484
+ bare hands when they wield none. Set `back_stab` for a thief's melee attack in the free
2485
+ round the party gets when it surprises the monsters.
2479
2486
  - `cast` uses `spell_id`, `spell_mode`, `reversed`, and `targets`. `spell_id` is a spell
2480
2487
  id, like `"sleep"`. `spell_mode` is the key of one of the spell's modes, and `reversed`
2481
2488
  picks the spell's reversed form.
@@ -2705,6 +2712,51 @@ class BattleDeclaration(BaseModel):
2705
2712
  basilisk meet its gaze" says. No guard protects against that gaze.
2706
2713
  [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) describes the
2707
2714
  gaze in full."""
2715
+ back_stab: bool = False
2716
+ """Make this `attack` a back-stab, or leave it `False` for an ordinary attack.
2717
+
2718
+ The OSE SRD's thief entry says "When attacking an unaware opponent from behind, a thief
2719
+ receives a +4 bonus to hit and doubles any damage dealt." osrlib has no facing in battle, so
2720
+ the session reads an unaware opponent from behind as a monster the party surprised, in the
2721
+ round it can't act. Set this field only when all of these are true:
2722
+
2723
+ - The action is `attack`.
2724
+ - The member's class has the `back_stab` ability, as a thief's does.
2725
+ - The monsters can't act this round because the party surprised them. That's the party's
2726
+ free round, while
2727
+ [`BattleState.monsters_hold_rounds`][osrlib.crawl.battle.BattleState.monsters_hold_rounds]
2728
+ is above zero. The session reads it when it accepts the round.
2729
+ - The attack is a melee attack. The weapon and the group's distance decide that, as
2730
+ [`weapon_id`][osrlib.crawl.commands.BattleDeclaration.weapon_id] describes.
2731
+
2732
+ An accepted back-stab hits the same monster an ordinary melee attack would. It gets +4 to
2733
+ hit, the defender's shield doesn't count, and its damage is doubled. The bonus and the
2734
+ doubling come from the class's `back_stab` ability. Every attack roll the declaration makes
2735
+ in the round is a back-stab, even when the monsters win initiative, and the attack's
2736
+ [`AttackRolledEvent`][osrlib.core.events.AttackRolledEvent] has `back_stab` set to true, so
2737
+ your game can say so.
2738
+
2739
+ The session refuses a back-stab with the first of these that applies, in this order. Each
2740
+ of the four back-stab codes has the member's id as its `character` param.
2741
+
2742
+ 1. `battle.declaration.back_stab_not_attack`: the action isn't `attack`. The session checks
2743
+ this before any other check on the declaration.
2744
+ 2. `battle.declaration.back_stab_no_ability`: the member's class has no `back_stab` ability.
2745
+ 3. `battle.declaration.back_stab_target_aware`: the monsters aren't surprised this round.
2746
+ That's every round of a battle the party didn't open by surprising the monsters, and
2747
+ every round after the free round.
2748
+ 4. The checks of an ordinary attack on its group and weapon:
2749
+ `battle.declaration.unknown_group`, `battle.declaration.no_target`, and
2750
+ `battle.declaration.weapon_not_wielded`.
2751
+ 5. `battle.declaration.back_stab_not_melee`: the attack the session would resolve is a
2752
+ missile attack. That's a missile weapon like a bow, or a weapon that's both melee and
2753
+ missile, like a dagger, aimed at a group farther away than 5'.
2754
+ 6. The rest of an ordinary melee attack's checks, like
2755
+ `battle.declaration.not_in_front_rank` and `combat.attack.out_of_reach`.
2756
+
2757
+ A declaration that leaves this field `False` resolves exactly as it would without it.
2758
+ [The adaptations register](https://mmacy.github.io/osrlib-python/adaptations/) describes the
2759
+ back-stab in full."""
2708
2760
 
2709
2761
 
2710
2762
  class ResolveBattleRound(Command):
@@ -2765,6 +2817,15 @@ class ResolveBattleRound(Command):
2765
2817
  `combat.attack.out_of_reach`, `combat.attack.out_of_range`,
2766
2818
  `combat.attack.reload`, `combat.attack.attacker_incapacitated`, and
2767
2819
  `combat.attack.attacker_blind`.
2820
+ - A declaration that sets `back_stab`: `battle.declaration.back_stab_not_attack` on
2821
+ any action but `attack`, before any other check. On an attack,
2822
+ `battle.declaration.back_stab_no_ability` and
2823
+ `battle.declaration.back_stab_target_aware` before the attack checks, and
2824
+ `battle.declaration.back_stab_not_melee` after the group and weapon checks and
2825
+ before the melee checks. A back-stab gets only the first of these that applies,
2826
+ and each has the member's id as its `character` param.
2827
+ [`BattleDeclaration.back_stab`][osrlib.crawl.commands.BattleDeclaration.back_stab]
2828
+ gives the order in full.
2768
2829
  - Cast declarations: `battle.declaration.missing_spell`,
2769
2830
  `battle.declaration.unknown_group`,
2770
2831
  `battle.declaration.invisible_target`, and the cast checks
@@ -3143,7 +3204,8 @@ class AddMember(Command):
3143
3204
  [`ReorderParty`][osrlib.crawl.commands.ReorderParty], which is legal in the same modes. The
3144
3205
  session logs the command, so a saved game and a replay both have the new member.
3145
3206
 
3146
- The new id has the form `character-NNNN` and is unique in the party. Read it from the
3207
+ The new id has the form `character-NNNN` and is unique in the party. It's never the id of a
3208
+ member who left with [`RemoveMember`][osrlib.crawl.commands.RemoveMember]. Read it from the
3147
3209
  event, and pass it as the `character_id` of later commands. The session adds a copy of
3148
3210
  `character`, not the object you pass. Your object stays as it was, with no id. Read the new
3149
3211
  member with [`GameSession.member`][osrlib.crawl.session.GameSession.member].
@@ -3205,6 +3267,83 @@ class AddMember(Command):
3205
3267
  return value.model_copy(deep=True)
3206
3268
 
3207
3269
 
3270
+ class RemoveMember(Command):
3271
+ """Referee: take a member out of the party for good.
3272
+
3273
+ Use it when a character leaves play: one who died too long ago for *raise dead*, a hireling
3274
+ who quits, or a character the player retires. To bring in a replacement, send
3275
+ [`AddMember`][osrlib.crawl.commands.AddMember] as well. The member can be in any state:
3276
+ alive, dead, turned to stone, or asleep. The other members keep their marching order.
3277
+
3278
+ Name another member in `give_to` to keep what the leaving member carries in the party.
3279
+ Every item and all the coins go to that member: mundane gear, magic items, gems, and
3280
+ jewellery. Equipped items arrive unequipped in their pack, and an equipped magic item's
3281
+ effects end, as with [`GiveItems`][osrlib.crawl.commands.GiveItems]. A cursed item moves
3282
+ too, even when its curse is revealed, because the curse holds only its wearer, and the wearer
3283
+ has left. It arrives with its curse hidden again, as *remove curse* leaves an item, so the
3284
+ new owner can drop it or give it away. If they put it on, the curse is revealed again.
3285
+ Without `give_to`, everything the member carries leaves the game with them.
3286
+
3287
+ The session also drops what it tracks about the member. The effects on them end without
3288
+ events, and their death record, their food and water counts, and the record of when they
3289
+ last prepared spells are deleted. An effect the member cast on another member lasts its full
3290
+ duration. A light the member carried goes out with them. A burning torch leaves with its
3291
+ bearer, and a lantern given to `give_to` arrives unlit. Read
3292
+ [`GameSession.party_light`][osrlib.crawl.session.GameSession.party_light] afterwards to find
3293
+ out whether the party still has light.
3294
+
3295
+ The member's id is retired. The session never gives it to another member, so an old event
3296
+ that names it can't be mistaken for a new member. A replacement that joins with `AddMember`
3297
+ gets a new id. The session refuses the old id in later commands with
3298
+ `session.command.unknown_member`.
3299
+
3300
+ In a dungeon, the coins and valuables that leave with the member come off
3301
+ [`treasure_snapshot_cp`][osrlib.crawl.session.GameSession.treasure_snapshot_cp], so the
3302
+ departure doesn't change the experience the party earns for treasure when it comes back to
3303
+ town. With `give_to`, nothing leaves the party, and the snapshot stays as it was.
3304
+
3305
+ Removing a member makes no draws and takes no time. The command is legal only in `town` and
3306
+ `exploring`, like `AddMember`. The session never removes the last member who stands, which
3307
+ means one who is neither dead nor turned to stone, because a party with nobody standing has
3308
+ lost the game. To replace a player's last standing character, add the new character with
3309
+ `AddMember` first, and then remove the old one.
3310
+
3311
+ Modes:
3312
+ `town`, `exploring`
3313
+
3314
+ Rejections:
3315
+ - `session.command.wrong_mode` - an encounter or a battle is open, or the session
3316
+ has ended.
3317
+ - `session.command.unknown_member` - `character_id` or `give_to` matches no party
3318
+ member.
3319
+ - `party.remove.same_member` - `give_to` names the member who leaves.
3320
+ - `session.command.member_incapacitated` - the `give_to` member can't act, because
3321
+ they're dead, asleep, paralysed, or turned to stone.
3322
+ - `party.remove.last_standing` - no member would be left standing. That includes
3323
+ removing the only member.
3324
+
3325
+ The session checks in this order and reports the first that fails.
3326
+
3327
+ Events:
3328
+ With `give_to`, an [`EffectReleasedEvent`][osrlib.core.events.EffectReleasedEvent]
3329
+ for each effect of an equipped magic item, then an
3330
+ [`ItemsGivenEvent`][osrlib.crawl.events.ItemsGivenEvent] with what moved, when
3331
+ anything did. Then a [`MemberRemovedEvent`][osrlib.crawl.events.MemberRemovedEvent]
3332
+ with the member's id and name.
3333
+ """
3334
+
3335
+ allowed_modes: ClassVar[frozenset[SessionMode]] = _FIELD_MODES
3336
+
3337
+ command_type: Literal["remove_member"] = "remove_member"
3338
+ character_id: str
3339
+ """The member who leaves, by [`MemberView.id`][osrlib.crawl.views.MemberView.id]. They can
3340
+ be alive, dead, asleep, or turned to stone."""
3341
+ give_to: str | None = None
3342
+ """The member who takes everything the leaving member carries, by
3343
+ [`MemberView.id`][osrlib.crawl.views.MemberView.id]. Leave it `None` to let it all leave the
3344
+ game with the member. The member named here has to be able to act."""
3345
+
3346
+
3208
3347
  class AwardXP(Command):
3209
3348
  """Referee: give one character experience points, apart from the XP the session awards.
3210
3349
 
@@ -4066,6 +4205,7 @@ ALL_COMMAND_CLASSES: tuple[type[Command], ...] = (
4066
4205
  RemoveItem,
4067
4206
  RemoveCoins,
4068
4207
  AddMember,
4208
+ RemoveMember,
4069
4209
  )
4070
4210
  """Every command class, in a fixed order. These are the members of the
4071
4211
  [`AnyCommand`][osrlib.crawl.commands.AnyCommand] union.
@@ -4140,6 +4280,9 @@ The other referee commands are left out for these reasons:
4140
4280
  so an authored roll would only use up draws from the adjudication stream.
4141
4281
  - [`AddMember`][osrlib.crawl.commands.AddMember] takes a whole character, and an adventure
4142
4282
  document doesn't contain characters.
4283
+ - [`RemoveMember`][osrlib.crawl.commands.RemoveMember] takes a player's character out of the
4284
+ game, which is a decision for your game, not for an adventure. It can also name a member to
4285
+ take the leaving member's goods, and no selector can name that member.
4143
4286
 
4144
4287
  In an authored consequence, the `character_id` of a grant, a removal, or an award is a
4145
4288
  selector, never a member id, because the session assigns member ids when it starts. The
@@ -123,6 +123,7 @@ __all__ = [
123
123
  "ListenedEvent",
124
124
  "LocationEnteredEvent",
125
125
  "MemberAddedEvent",
126
+ "MemberRemovedEvent",
126
127
  "MemberWithdrewEvent",
127
128
  "MonsterDefeatedEvent",
128
129
  "MonsterFledEvent",
@@ -2146,6 +2147,36 @@ class MemberAddedEvent(Event):
2146
2147
  """The new member's experience level when they joined."""
2147
2148
 
2148
2149
 
2150
+ class MemberRemovedEvent(Event):
2151
+ """A member left the party during play.
2152
+
2153
+ Emitted by [`RemoveMember`][osrlib.crawl.commands.RemoveMember]. The member is already gone
2154
+ from the party and from the views, so take them off the party on screen. When `given_to` is
2155
+ set, the [`ItemsGivenEvent`][osrlib.crawl.events.ItemsGivenEvent] just before this event
2156
+ lists what that member took. The event doesn't say why the member left. The command does:
2157
+ read the `source` of the `RemoveMember`, either the one you executed or its entry in
2158
+ [`GameSession.command_log`][osrlib.crawl.session.GameSession.command_log].
2159
+ """
2160
+
2161
+ allowed_codes: ClassVar[frozenset[str]] = frozenset({"session.member.removed"})
2162
+ """The only message code this event uses."""
2163
+
2164
+ event_type: Literal["member_removed"] = "member_removed"
2165
+ """The event type, `member_removed`, which identifies this class in serialized form."""
2166
+ code: str = "session.member.removed"
2167
+ """The message code, always `session.member.removed`."""
2168
+ visibility: Visibility = Visibility.PLAYER
2169
+ """Player visibility: the players see who leaves them."""
2170
+ character_id: str
2171
+ """The id the member had, like `"character-0002"`. The session never gives it to another
2172
+ member, so earlier events that name it still mean this character."""
2173
+ name: str
2174
+ """The member's name, so you can show the event without the member at hand."""
2175
+ given_to: str | None = None
2176
+ """The id of the member who took everything the leaving member carried, or `None` when it
2177
+ all left the game with them."""
2178
+
2179
+
2149
2180
  CRAWL_EVENT_CLASSES: tuple[type[Event], ...] = (
2150
2181
  PartyMovedEvent,
2151
2182
  LocationEnteredEvent,
@@ -2206,6 +2237,7 @@ CRAWL_EVENT_CLASSES: tuple[type[Event], ...] = (
2206
2237
  AdventureCompletedEvent,
2207
2238
  ItemsRemovedEvent,
2208
2239
  MemberAddedEvent,
2240
+ MemberRemovedEvent,
2209
2241
  )
2210
2242
  """The event classes defined in this module, in the order they're declared.
2211
2243
 
@@ -22,11 +22,12 @@ Three things change the list:
22
22
  the member named in
23
23
  [`BattleDeclaration.replacement_id`][osrlib.crawl.commands.BattleDeclaration.replacement_id].
24
24
  The new order lasts after the battle.
25
- - [`AddMember`][osrlib.crawl.commands.AddMember] adds a new member at the back. It's a referee
26
- command, one your game logic issues rather than a player.
25
+ - [`AddMember`][osrlib.crawl.commands.AddMember] adds a new member at the back, and
26
+ [`RemoveMember`][osrlib.crawl.commands.RemoveMember] takes a member out for good. Both are
27
+ referee commands, ones your game logic issues rather than a player.
27
28
 
28
- Dead members stay in the list, with their gear still on them. No command removes a member. The
29
- rules that count party members skip the dead: movement rate, combat ranks, traps, and food and
29
+ Dead members stay in the list, with their gear still on them, until a `RemoveMember` takes them
30
+ out. The rules that count party members skip the dead: movement rate, combat ranks, traps, and food and
30
31
  water all use [`living_members`][osrlib.crawl.party.Party.living_members].
31
32
  """
32
33
 
@@ -96,6 +97,8 @@ class Party(BaseModel):
96
97
 
97
98
  - To add a member during play, execute [`AddMember`][osrlib.crawl.commands.AddMember]. It
98
99
  assigns the new member's id and puts them at the back.
100
+ - To take a member out of play, execute
101
+ [`RemoveMember`][osrlib.crawl.commands.RemoveMember].
99
102
  - To change the order outside a fight, execute
100
103
  [`ReorderParty`][osrlib.crawl.commands.ReorderParty].
101
104
  - In a battle, a member who withdraws alone names the member to trade places with in
@@ -306,14 +309,17 @@ class Party(BaseModel):
306
309
  self.members = [by_id[character_id] for character_id in character_ids]
307
310
 
308
311
 
312
+ def _stands(member: Character) -> bool:
313
+ """Whether a member stands, which means they are neither dead nor petrified."""
314
+ return not has_condition(member, Condition.DEAD) and not has_condition(member, Condition.PETRIFIED)
315
+
316
+
309
317
  def _anyone_standing(party: Party) -> bool:
310
318
  """Whether any member stands, which means they are neither dead nor petrified.
311
319
 
312
- The session's `game_over` check and a battle's defeat check ask this. A statue still counts
313
- among [`living_members`][osrlib.crawl.party.Party.living_members] for every other rule, so a
314
- party turned wholly to stone has living members but nobody standing.
320
+ The session's `game_over` check and a battle's defeat check ask this, and
321
+ [`RemoveMember`][osrlib.crawl.commands.RemoveMember] refuses to take out the last member who
322
+ stands. A statue still counts among [`living_members`][osrlib.crawl.party.Party.living_members]
323
+ for every other rule, so a party turned wholly to stone has living members but nobody standing.
315
324
  """
316
- return any(
317
- not has_condition(member, Condition.DEAD) and not has_condition(member, Condition.PETRIFIED)
318
- for member in party.members
319
- )
325
+ return any(_stands(member) for member in party.members)
@@ -101,6 +101,7 @@ print(restored.view(Visibility.PLAYER) == view)
101
101
  # changes nothing, and spends no time until the last rejection check has passed. The referee and quest
102
102
  # command handlers are at the bottom of this module.
103
103
 
104
+ import re
104
105
  from collections.abc import Mapping, Sequence
105
106
  from typing import TYPE_CHECKING, Any, Literal, Protocol
106
107
 
@@ -110,6 +111,7 @@ from osrlib.core.alignment import Alignment
110
111
  from osrlib.core.character import ADVANCEMENT_STREAM, Character
111
112
  from osrlib.core.classes import XpAwardResult, apply_xp, level_title
112
113
  from osrlib.core.clock import ROUNDS_PER_DAY, ROUNDS_PER_TURN, GameClock, TimeUnit
114
+ from osrlib.core.combat import incapacitated
113
115
  from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectsLedger
114
116
  from osrlib.core.events import (
115
117
  ConditionGainedEvent,
@@ -121,7 +123,7 @@ from osrlib.core.events import (
121
123
  SavingThrowRolledEvent,
122
124
  Visibility,
123
125
  )
124
- from osrlib.core.items import EquipmentCatalog, ItemInstance, MagicItemInstance
126
+ from osrlib.core.items import EquipmentCatalog, Inventory, ItemInstance, MagicItemInstance
125
127
  from osrlib.core.monsters import MONSTER_SPAWN_STREAM, IdAllocator, MonsterCatalog, MonsterInstance, spawn_monster
126
128
  from osrlib.core.rng import RngStreams, StreamName
127
129
  from osrlib.core.ruleset import Ruleset
@@ -147,6 +149,7 @@ from osrlib.crawl.commands import (
147
149
  RecordNote,
148
150
  RemoveCoins,
149
151
  RemoveItem,
152
+ RemoveMember,
150
153
  RevealObjective,
151
154
  RollDice,
152
155
  SessionMode,
@@ -164,11 +167,13 @@ from osrlib.crawl.events import (
164
167
  FlagSetEvent,
165
168
  GameOverEvent,
166
169
  ItemAcquiredEvent,
170
+ ItemsGivenEvent,
167
171
  ItemsRemovedEvent,
168
172
  JournalEntryAddedEvent,
169
173
  LightEvent,
170
174
  LocationEnteredEvent,
171
175
  MemberAddedEvent,
176
+ MemberRemovedEvent,
172
177
  MonstersSpawnedEvent,
173
178
  NoteRecordedEvent,
174
179
  ObjectiveCompletedEvent,
@@ -179,7 +184,7 @@ from osrlib.crawl.events import (
179
184
  TriggerFiredEvent,
180
185
  XpAwardedEvent,
181
186
  )
182
- from osrlib.crawl.party import Party, _anyone_standing
187
+ from osrlib.crawl.party import Party, _anyone_standing, _stands
183
188
  from osrlib.crawl.quests import ObjectiveSpec, QuestSpec
184
189
  from osrlib.data import load_equipment, load_magic_items, load_monsters
185
190
  from osrlib.errors import ContentValidationError
@@ -2290,6 +2295,113 @@ def _handle_add_member(session: GameSession, command: AddMember) -> tuple[list[R
2290
2295
  ]
2291
2296
 
2292
2297
 
2298
+ def _handle_remove_member(session: GameSession, command: RemoveMember) -> tuple[list[Rejection], list[Event]]:
2299
+ try:
2300
+ member = session.member(command.character_id)
2301
+ except ValueError:
2302
+ return [Rejection(code="session.command.unknown_member", params={"character": command.character_id})], []
2303
+ recipient: Character | None = None
2304
+ if command.give_to is not None:
2305
+ try:
2306
+ recipient = session.member(command.give_to)
2307
+ except ValueError:
2308
+ return [Rejection(code="session.command.unknown_member", params={"character": command.give_to})], []
2309
+ if recipient is member:
2310
+ return [Rejection(code="party.remove.same_member", params={"character": command.give_to})], []
2311
+ if incapacitated(recipient):
2312
+ return [Rejection(code="session.command.member_incapacitated", params={"character": command.give_to})], []
2313
+ if not any(_stands(other) for other in session.party.members if other is not member):
2314
+ return [Rejection(code="party.remove.last_standing", params={"character": command.character_id})], []
2315
+ member_id = _member_id(member)
2316
+ events: list[Event] = []
2317
+ if recipient is not None:
2318
+ events.extend(_hand_over_everything(session, member, recipient))
2319
+ elif session.treasure_snapshot_cp is not None:
2320
+ # The coins and valuables leave the party's total, so the baseline the return award
2321
+ # measures against drops by the same amount, and the award doesn't change.
2322
+ session.treasure_snapshot_cp -= member.inventory.purse.value_cp + sum(
2323
+ valuable.value_gp * 100 for valuable in member.inventory.valuables
2324
+ )
2325
+ # Whatever the member still carries leaves with them, and so does every effect on them. An
2326
+ # effect the member cast on another member targets that other member, so it stays.
2327
+ session.ledger.effects = [effect for effect in session.ledger.effects if effect.target_ref != member_id]
2328
+ session.death_records.pop(member_id, None)
2329
+ session.deprivation.pop(member_id, None)
2330
+ session.last_prepared_sleep.pop(member_id, None)
2331
+ session.party.members = [other for other in session.party.members if other is not member]
2332
+ _retire_member_id(session, member_id)
2333
+ events.append(MemberRemovedEvent(character_id=member_id, name=member.name, given_to=command.give_to))
2334
+ return [], events
2335
+
2336
+
2337
+ def _hand_over_everything(session: GameSession, giver: Character, recipient: Character) -> list[Event]:
2338
+ """Move everything a leaving member carries to `recipient`, as a `GiveItems` of all of it would.
2339
+
2340
+ An equipped magic item releases its effects first, with events, and every item arrives
2341
+ unequipped in the recipient's pack. Mundane gear joins the recipient's stacks. A revealed
2342
+ curse held only its wearer, so a cursed item arrives no longer stuck, as *remove curse* would
2343
+ leave it. Returns the releases and one `ItemsGivenEvent`, or no events when the member
2344
+ carried nothing.
2345
+ """
2346
+ from osrlib.crawl import exploration
2347
+
2348
+ inventory = giver.inventory
2349
+ events: list[Event] = []
2350
+ item_ids: list[str] = []
2351
+ for instance in inventory.all_instances():
2352
+ if isinstance(instance, MagicItemInstance):
2353
+ events.extend(exploration._release_instance_effects(session, instance))
2354
+ instance.cursed_revealed = False
2355
+ recipient.inventory.items.append(instance)
2356
+ item_ids.append(instance.instance_id)
2357
+ else:
2358
+ exploration._grant_mundane(recipient, instance.template, instance.quantity)
2359
+ item_ids.extend([instance.template.id] * instance.quantity)
2360
+ for valuable in inventory.valuables:
2361
+ recipient.inventory.valuables.append(valuable)
2362
+ item_ids.append(valuable.instance_id)
2363
+ purse = inventory.purse
2364
+ moved_cp = purse.value_cp
2365
+ moved_coins = purse.total_coins
2366
+ recipient_purse = recipient.inventory.purse
2367
+ for denomination in ("pp", "gp", "ep", "sp", "cp"):
2368
+ setattr(recipient_purse, denomination, getattr(recipient_purse, denomination) + getattr(purse, denomination))
2369
+ giver.inventory = Inventory()
2370
+ if item_ids or moved_coins:
2371
+ events.append(
2372
+ ItemsGivenEvent(
2373
+ character_id=_member_id(giver),
2374
+ recipient_id=_member_id(recipient),
2375
+ item_ids=tuple(item_ids),
2376
+ coins_gp_value=moved_cp // 100,
2377
+ )
2378
+ )
2379
+ return events
2380
+
2381
+
2382
+ def _retire_member_id(session: GameSession, member_id: str) -> None:
2383
+ """Make sure the allocator never hands out a removed member's id.
2384
+
2385
+ The allocator counts up and never goes back, so moving its `character` count up to the
2386
+ removed id's number retires that id and every one below it. That holds for an id the
2387
+ allocator never issued, like one a member brought from an earlier session. The count lives
2388
+ in the allocator, which a save contains, and a replay executes the removal again. An id
2389
+ that isn't in the allocator's `character-NNNN` form is one the allocator can't produce, so
2390
+ it needs nothing.
2391
+ """
2392
+ match = _ALLOCATED_CHARACTER_ID.fullmatch(member_id)
2393
+ if match is None:
2394
+ return
2395
+ number = int(match.group(1))
2396
+ if member_id != f"character-{number:04d}":
2397
+ return
2398
+ if number > session.allocator.counters.get("character", 0):
2399
+ session.allocator.counters["character"] = number
2400
+
2401
+
2402
+ _ALLOCATED_CHARACTER_ID = re.compile(r"character-([0-9]+)")
2403
+
2404
+
2293
2405
  def _handle_award_xp(session: GameSession, command: AwardXP) -> tuple[list[Rejection], list[Event]]:
2294
2406
  try:
2295
2407
  member = session.member(command.character_id)
@@ -2714,4 +2826,5 @@ _REFEREE_HANDLERS = {
2714
2826
  RemoveItem: _handle_remove_item,
2715
2827
  RemoveCoins: _handle_remove_coins,
2716
2828
  AddMember: _handle_add_member,
2829
+ RemoveMember: _handle_remove_member,
2717
2830
  }
@@ -93,21 +93,27 @@ def _initiative(event: InitiativeRolledEvent) -> str:
93
93
 
94
94
  def _attack_hit(event: AttackRolledEvent) -> str:
95
95
  natural = " (natural 20)" if event.natural == 20 else ""
96
+ verb = "back-stabs" if event.back_stab else "hits"
96
97
  return (
97
- f"{event.attacker_id} hits {event.defender_id} with {event.attack_name}: "
98
+ f"{event.attacker_id} {verb} {event.defender_id} with {event.attack_name}: "
98
99
  f"rolled {event.roll}{event.modifier:+d} = {event.total}, needing {event.required}{natural}."
99
100
  )
100
101
 
101
102
 
102
103
  def _attack_missed(event: AttackRolledEvent) -> str:
103
104
  natural = " (natural 1)" if event.natural == 1 else ""
105
+ attempt = f"a back-stab on {event.defender_id}" if event.back_stab else event.defender_id
104
106
  return (
105
- f"{event.attacker_id} misses {event.defender_id} with {event.attack_name}: "
107
+ f"{event.attacker_id} misses {attempt} with {event.attack_name}: "
106
108
  f"rolled {event.roll}{event.modifier:+d} = {event.total}, needing {event.required}{natural}."
107
109
  )
108
110
 
109
111
 
110
112
  def _attack_auto(event: AttackRolledEvent) -> str:
113
+ if event.back_stab:
114
+ return (
115
+ f"{event.attacker_id} back-stabs the helpless {event.defender_id} with {event.attack_name}. No roll needed."
116
+ )
111
117
  return f"{event.attacker_id} strikes the helpless {event.defender_id} with {event.attack_name} — no roll needed."
112
118
 
113
119
 
@@ -450,6 +456,10 @@ _TEMPLATES: dict[str, Callable[[Any], str]] = {
450
456
  "session.member.added": lambda event: (
451
457
  f"{event.name}, a level {event.level} {event.class_id}, joins the party as {event.character_id}."
452
458
  ),
459
+ "session.member.removed": lambda event: (
460
+ f"{event.name} leaves the party"
461
+ + (f", and {event.given_to} takes their belongings." if event.given_to else ".")
462
+ ),
453
463
  }
454
464
 
455
465
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes