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.
- {osrlib-2.1.0 → osrlib-2.2.0}/PKG-INFO +1 -1
- {osrlib-2.1.0 → osrlib-2.2.0}/pyproject.toml +1 -1
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/combat.py +14 -7
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/events.py +10 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/battle.py +49 -1
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/commands.py +151 -8
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/events.py +32 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/party.py +17 -11
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/session.py +115 -2
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/messages.py +12 -2
- {osrlib-2.1.0 → osrlib-2.2.0}/LICENSE +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/LICENSE-OGL.md +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/README.md +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/__init__.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/__init__.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/abilities.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/alignment.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/character.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/classes.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/clock.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/creature.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/dice.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/effects.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/items.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/monsters.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/npc.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/rng.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/ruleset.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/spells.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/tables.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/treasure.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/core/validation.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/__init__.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/adventure.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/content_pack.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/dungeon.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/encounter.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/exploration.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/gates.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/interpreter.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/narrative.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/quests.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/stocking.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/triggers.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/crawl/views.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/LICENSE-OGL.md +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/__init__.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/abilities.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/classes.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/combat_tables.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/encounter_tables.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/equipment.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/languages.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/magic_items.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/monsters.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/spells.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/data/treasure.json +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/errors.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/persistence.py +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/py.typed +0 -0
- {osrlib-2.1.0 → osrlib-2.2.0}/src/osrlib/versioning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "osrlib"
|
|
3
|
-
version = "2.
|
|
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
|
|
1501
|
-
|
|
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
|
-
|
|
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]
|
|
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],
|
|
393
|
-
[`AddMember`][osrlib.crawl.commands.AddMember]
|
|
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
|
|
2477
|
-
member attacks with the wielded weapon that suits the attack, or with
|
|
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.
|
|
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
|
|
26
|
-
|
|
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
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
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}
|
|
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 {
|
|
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
|
|
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
|
|
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
|