rom24-quickmud-python 2.8.21__py3-none-any.whl → 2.9.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mud/account/account_manager.py +10 -4
- mud/affects/engine.py +4 -0
- mud/ai/__init__.py +9 -11
- mud/combat/death.py +3 -1
- mud/combat/engine.py +157 -50
- mud/combat/messages.py +67 -31
- mud/commands/advancement.py +14 -8
- mud/commands/auto_settings.py +32 -3
- mud/commands/combat.py +101 -56
- mud/commands/communication.py +103 -17
- mud/commands/dispatcher.py +15 -2
- mud/commands/imm_emote.py +8 -3
- mud/commands/info.py +10 -6
- mud/commands/info_extended.py +88 -51
- mud/commands/inventory.py +5 -2
- mud/commands/misc_info.py +155 -146
- mud/commands/session.py +36 -33
- mud/game_loop.py +75 -37
- mud/handler.py +26 -21
- mud/loaders/base_loader.py +2 -4
- mud/loaders/json_loader.py +9 -5
- mud/loaders/obj_loader.py +9 -5
- mud/loaders/room_loader.py +21 -2
- mud/mob_cmds.py +3 -9
- mud/models/__init__.py +1 -2
- mud/models/character.py +20 -12
- mud/models/obj.py +11 -49
- mud/models/object.py +63 -2
- mud/models/room.py +14 -1
- mud/music/__init__.py +5 -4
- mud/net/connection.py +155 -88
- mud/network/websocket_stream.py +2 -1
- mud/rom_api.py +18 -5
- mud/scripts/convert_are_to_json.py +1 -1
- mud/skills/handlers.py +51 -57
- mud/spawning/obj_spawner.py +6 -1
- mud/spawning/templates.py +0 -1
- mud/world/look.py +18 -29
- mud/world/vision.py +31 -0
- {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/METADATA +17 -16
- {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/RECORD +45 -45
- {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/WHEEL +0 -0
- {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/entry_points.txt +0 -0
- {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/licenses/LICENSE +0 -0
- {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/top_level.txt +0 -0
mud/account/account_manager.py
CHANGED
|
@@ -57,10 +57,16 @@ def load_character(char_name: str, _ignored: str | None = None) -> Character | N
|
|
|
57
57
|
return None
|
|
58
58
|
char = from_orm(db_char)
|
|
59
59
|
if char is not None:
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
# INV-009 REGISTRY-DISCONNECT-CLEANUP / dedup-by-name:
|
|
61
|
+
# ROM only ever has one player object by a given name in
|
|
62
|
+
# char_list. Drop any prior Character with the same name
|
|
63
|
+
# (e.g. the level=0 bare-row load during nanny name/password
|
|
64
|
+
# phase) before appending the freshly-loaded one. This
|
|
65
|
+
# complements INV-003 and prevents in-session duplicates
|
|
66
|
+
# surfacing through the promote-from-bare-row path.
|
|
67
|
+
for prior in [c for c in character_registry if getattr(c, "name", None) == char.name]:
|
|
68
|
+
character_registry.remove(prior)
|
|
69
|
+
character_registry.append(char) # INV-003
|
|
64
70
|
return char
|
|
65
71
|
except Exception as e:
|
|
66
72
|
print(f"[ERROR] Failed to load character {char_name} from DB: {e}")
|
mud/affects/engine.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from mud.models.character import Character
|
|
4
|
+
from mud.utils import rng_mm
|
|
4
5
|
|
|
5
6
|
ROM_NEWLINE = "\n\r"
|
|
6
7
|
|
|
@@ -24,6 +25,9 @@ def tick_spell_effects(character: Character) -> list[str]:
|
|
|
24
25
|
duration = int(getattr(affect, "duration", 0) or 0)
|
|
25
26
|
if duration > 0:
|
|
26
27
|
affect.duration = duration - 1
|
|
28
|
+
level = int(getattr(affect, "level", 0) or 0)
|
|
29
|
+
if level > 0 and rng_mm.number_range(0, 4) == 0:
|
|
30
|
+
affect.level = level - 1 # mirroring ROM src/update.c:765-768
|
|
27
31
|
spell_name = getattr(affect, "type", None)
|
|
28
32
|
if isinstance(spell_name, str) and spell_name in effects:
|
|
29
33
|
touched_names.add(spell_name)
|
mud/ai/__init__.py
CHANGED
|
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Iterable
|
|
6
6
|
|
|
7
|
+
import mud.mobprog as mobprog
|
|
7
8
|
from mud.models.character import Character, character_registry
|
|
8
9
|
from mud.models.constants import (
|
|
10
|
+
EX_CLOSED,
|
|
9
11
|
ActFlag,
|
|
10
12
|
AffectFlag,
|
|
11
13
|
Direction,
|
|
@@ -13,18 +15,14 @@ from mud.models.constants import (
|
|
|
13
15
|
Position,
|
|
14
16
|
RoomFlag,
|
|
15
17
|
WearFlag,
|
|
16
|
-
EX_CLOSED,
|
|
17
18
|
)
|
|
18
|
-
from mud.models.
|
|
19
|
+
from mud.models.object import Object
|
|
19
20
|
from mud.models.room import Exit, Room
|
|
20
|
-
from mud.registry import room_registry
|
|
21
21
|
from mud.utils import rng_mm
|
|
22
22
|
from mud.world.movement import move_character
|
|
23
23
|
|
|
24
24
|
from .aggressive import aggressive_update
|
|
25
25
|
|
|
26
|
-
import mud.mobprog as mobprog
|
|
27
|
-
|
|
28
26
|
__all__ = ["aggressive_update", "mobile_update"]
|
|
29
27
|
|
|
30
28
|
|
|
@@ -114,7 +112,7 @@ def _broadcast_room(room: Room, message: str, exclude: object | None = None) ->
|
|
|
114
112
|
messages.append(message)
|
|
115
113
|
|
|
116
114
|
|
|
117
|
-
def _can_loot(mob: Character, obj:
|
|
115
|
+
def _can_loot(mob: Character, obj: Object) -> bool:
|
|
118
116
|
if getattr(mob, "is_admin", False):
|
|
119
117
|
return True
|
|
120
118
|
is_immortal = getattr(mob, "is_immortal", None)
|
|
@@ -150,7 +148,7 @@ def _can_loot(mob: Character, obj: ObjectData) -> bool:
|
|
|
150
148
|
return False
|
|
151
149
|
|
|
152
150
|
|
|
153
|
-
def _room_contents(room: Room | None) -> Iterable[
|
|
151
|
+
def _room_contents(room: Room | None) -> Iterable[Object]:
|
|
154
152
|
if room is None:
|
|
155
153
|
return []
|
|
156
154
|
contents = getattr(room, "contents", None)
|
|
@@ -159,7 +157,7 @@ def _room_contents(room: Room | None) -> Iterable[ObjectData]:
|
|
|
159
157
|
return []
|
|
160
158
|
|
|
161
159
|
|
|
162
|
-
def _take_object(mob: Character, obj:
|
|
160
|
+
def _take_object(mob: Character, obj: Object) -> None:
|
|
163
161
|
room = getattr(obj, "in_room", None) or getattr(obj, "location", None) or getattr(mob, "room", None)
|
|
164
162
|
if room is not None:
|
|
165
163
|
contents = getattr(room, "contents", None)
|
|
@@ -201,7 +199,7 @@ def _maybe_scavenge(mob: Character, room: Room) -> None:
|
|
|
201
199
|
if rng_mm.number_bits(6) != 0:
|
|
202
200
|
return
|
|
203
201
|
|
|
204
|
-
best_obj:
|
|
202
|
+
best_obj: Object | None = None
|
|
205
203
|
best_cost = 1
|
|
206
204
|
for obj in contents:
|
|
207
205
|
wear_flags = int(getattr(obj, "wear_flags", 0) or 0)
|
|
@@ -300,8 +298,8 @@ def mobile_update() -> None:
|
|
|
300
298
|
if gold * 100 + silver < wealth:
|
|
301
299
|
gold += wealth * rng_mm.number_range(1, 20) // 5_000_000
|
|
302
300
|
silver += wealth * rng_mm.number_range(1, 20) // 50_000
|
|
303
|
-
|
|
304
|
-
|
|
301
|
+
mob.gold = gold
|
|
302
|
+
mob.silver = silver
|
|
305
303
|
|
|
306
304
|
default_pos_raw = getattr(mob, "default_pos", getattr(mob, "position", Position.STANDING))
|
|
307
305
|
try:
|
mud/combat/death.py
CHANGED
|
@@ -438,7 +438,9 @@ def make_corpse(victim: Character) -> Object | None:
|
|
|
438
438
|
money_obj = create_money(gold, silver)
|
|
439
439
|
if money_obj:
|
|
440
440
|
corpse.contained_items.append(money_obj)
|
|
441
|
-
|
|
441
|
+
# ROM src/handler.c:1968 obj_to_obj — money lives inside corpse,
|
|
442
|
+
# so in_obj=corpse, in_room=None, carried_by=None.
|
|
443
|
+
money_obj.location = corpse
|
|
442
444
|
|
|
443
445
|
victim.gold = 0
|
|
444
446
|
victim.silver = 0
|
mud/combat/engine.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
from types import SimpleNamespace
|
|
5
4
|
|
|
6
5
|
from mud import mobprog
|
|
7
6
|
from mud.account.account_manager import save_character
|
|
@@ -38,9 +37,8 @@ from mud.models.constants import (
|
|
|
38
37
|
WearFlag,
|
|
39
38
|
attack_damage_type,
|
|
40
39
|
)
|
|
41
|
-
from mud.models.weapon_table import weapon_skill_name_for_type
|
|
42
40
|
from mud.models.social import expand_placeholders
|
|
43
|
-
from mud.
|
|
41
|
+
from mud.models.weapon_table import weapon_skill_name_for_type
|
|
44
42
|
from mud.skills import check_improve
|
|
45
43
|
from mud.utils import rng_mm
|
|
46
44
|
from mud.wiznet import WiznetFlag, wiznet
|
|
@@ -227,19 +225,35 @@ def _push_message(character: Character | None, message: str) -> None:
|
|
|
227
225
|
mailbox.append(message)
|
|
228
226
|
|
|
229
227
|
|
|
230
|
-
def
|
|
228
|
+
def _broadcast_damage_messages(
|
|
231
229
|
attacker: Character,
|
|
232
230
|
victim: Character,
|
|
233
231
|
messages: DamageMessages | None,
|
|
234
232
|
) -> None:
|
|
233
|
+
"""Render `dam_message` templates per-recipient through ROM PERS.
|
|
234
|
+
|
|
235
|
+
Mirrors ROM `src/fight.c:2218-2228` — three `act()` calls
|
|
236
|
+
(TO_NOTVICT/TO_CHAR/TO_VICT) each evaluate `PERS(ch, looker)`
|
|
237
|
+
and `PERS(victim, looker)` independently for each observer.
|
|
238
|
+
Python previously emitted pre-rendered strings keyed on
|
|
239
|
+
`attacker.name`/`victim.name`, leaking identities to every
|
|
240
|
+
recipient regardless of `can_see` (DAMMSG-001/002/003).
|
|
241
|
+
"""
|
|
242
|
+
from mud.combat.messages import render_for as _render
|
|
243
|
+
|
|
235
244
|
if messages is None:
|
|
236
245
|
return
|
|
237
246
|
|
|
238
247
|
if messages.attacker:
|
|
239
|
-
|
|
248
|
+
# TO_CHAR — render with observer=attacker (attacker sees own
|
|
249
|
+
# actor as "You" in template; $N renders victim through
|
|
250
|
+
# PERS(victim, attacker)).
|
|
251
|
+
_push_message(attacker, _render(messages.attacker, attacker, victim, attacker) or "")
|
|
240
252
|
|
|
241
253
|
if not messages.self_inflicted and messages.victim:
|
|
242
|
-
|
|
254
|
+
# TO_VICT — render with observer=victim (victim sees $n
|
|
255
|
+
# attacker through PERS(attacker, victim)).
|
|
256
|
+
_push_message(victim, _render(messages.victim, attacker, victim, victim) or "")
|
|
243
257
|
|
|
244
258
|
if not messages.room:
|
|
245
259
|
return
|
|
@@ -248,14 +262,17 @@ def _dispatch_damage_messages(
|
|
|
248
262
|
if room is None:
|
|
249
263
|
return
|
|
250
264
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
for occupant in getattr(room, "people", []):
|
|
265
|
+
# TO_NOTVICT — iterate room.people and PERS-render per recipient
|
|
266
|
+
# so each observer sees attacker/victim per their own visibility.
|
|
267
|
+
for occupant in list(getattr(room, "people", [])):
|
|
256
268
|
if occupant is attacker or occupant is victim:
|
|
257
269
|
continue
|
|
258
|
-
_push_message(occupant, messages.room)
|
|
270
|
+
_push_message(occupant, _render(messages.room, attacker, victim, occupant) or "")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# Back-compat alias — the old name is referenced in older session
|
|
274
|
+
# notes and some test files. Both names invoke the same logic.
|
|
275
|
+
_dispatch_damage_messages = _broadcast_damage_messages
|
|
259
276
|
|
|
260
277
|
|
|
261
278
|
def get_weapon_skill(attacker: Character, weapon_sn: str | None) -> int:
|
|
@@ -310,13 +327,6 @@ def multi_hit(attacker: Character, victim: Character, dt: str | int | None = Non
|
|
|
310
327
|
"""
|
|
311
328
|
results: list[str] = []
|
|
312
329
|
|
|
313
|
-
# ROM src/fight.c:192-196 decrements wait/daze for descriptor-less characters
|
|
314
|
-
# PULSE_VIOLENCE is typically 3 in ROM
|
|
315
|
-
PULSE_VIOLENCE = 3
|
|
316
|
-
if not hasattr(attacker, "desc") or attacker.desc is None:
|
|
317
|
-
attacker.wait = max(0, getattr(attacker, "wait", 0) - PULSE_VIOLENCE)
|
|
318
|
-
attacker.daze = max(0, getattr(attacker, "daze", 0) - PULSE_VIOLENCE)
|
|
319
|
-
|
|
320
330
|
# Position check - no attacks if position too low
|
|
321
331
|
if attacker.position < Position.RESTING:
|
|
322
332
|
return results
|
|
@@ -582,7 +592,8 @@ def apply_damage(
|
|
|
582
592
|
|
|
583
593
|
if damage <= 0:
|
|
584
594
|
if message_bundle and message_bundle.attacker:
|
|
585
|
-
|
|
595
|
+
from mud.combat.messages import render_for as _render
|
|
596
|
+
return _render(message_bundle.attacker, attacker, victim, attacker) or ""
|
|
586
597
|
return "Your attack has no effect."
|
|
587
598
|
|
|
588
599
|
# Apply damage
|
|
@@ -624,7 +635,8 @@ def apply_damage(
|
|
|
624
635
|
return message
|
|
625
636
|
|
|
626
637
|
if message_bundle and message_bundle.attacker:
|
|
627
|
-
|
|
638
|
+
from mud.combat.messages import render_for as _render
|
|
639
|
+
return _render(message_bundle.attacker, attacker, victim, attacker) or ""
|
|
628
640
|
return ""
|
|
629
641
|
|
|
630
642
|
|
|
@@ -698,36 +710,91 @@ def is_awake(character: Character) -> bool:
|
|
|
698
710
|
return character.position > Position.SLEEPING
|
|
699
711
|
|
|
700
712
|
|
|
713
|
+
def _broadcast_pos_change(victim: Character, template: str, **extra: object) -> None:
|
|
714
|
+
"""Render `$n`-style room broadcasts per-listener via ROM PERS.
|
|
715
|
+
|
|
716
|
+
Mirrors ROM's `act(..., TO_ROOM)` macro, which evaluates
|
|
717
|
+
``PERS(victim, looker)`` once per recipient (see src/comm.c:act_new
|
|
718
|
+
around the `$n` case). Each room observer gets its own substituted
|
|
719
|
+
message, so an invisible victim renders as "someone" to anyone
|
|
720
|
+
without DETECT_INVIS — matches the channel-arc fix pattern
|
|
721
|
+
(mud/world/vision.py:pers).
|
|
722
|
+
|
|
723
|
+
`template` must contain a `{name}` placeholder for the
|
|
724
|
+
PERS-rendered victim name; any additional substitutions (e.g.
|
|
725
|
+
`{weapon}` for ROM `$p`) come from `**extra`. Weapons are not
|
|
726
|
+
`can_see`-gated, so they pass through verbatim.
|
|
727
|
+
"""
|
|
728
|
+
from mud.net.protocol import send_to_char as _send
|
|
729
|
+
from mud.world.vision import pers
|
|
730
|
+
|
|
731
|
+
room = getattr(victim, "room", None)
|
|
732
|
+
if room is None:
|
|
733
|
+
return
|
|
734
|
+
for listener in list(getattr(room, "people", [])):
|
|
735
|
+
if listener is victim:
|
|
736
|
+
continue
|
|
737
|
+
message = template.format(name=pers(victim, listener), **extra)
|
|
738
|
+
writer = getattr(listener, "connection", None)
|
|
739
|
+
if writer is not None:
|
|
740
|
+
asyncio.create_task(_send(listener, message))
|
|
741
|
+
if hasattr(listener, "messages"):
|
|
742
|
+
listener.messages.append(message)
|
|
743
|
+
|
|
744
|
+
|
|
701
745
|
def _position_change_message(victim: Character, old_pos: Position) -> str:
|
|
702
746
|
"""Generate position change message following ROM logic."""
|
|
703
747
|
if victim.position == Position.MORTAL:
|
|
748
|
+
# mirroring ROM src/fight.c:837-838 — `act("$n is mortally
|
|
749
|
+
# wounded, and will die soon, if not aided.", victim, NULL,
|
|
750
|
+
# NULL, TO_ROOM)`. The act() macro renders `$n` per-listener
|
|
751
|
+
# through PERS(victim, looker), so an invisible victim
|
|
752
|
+
# appears as "someone" to room observers without
|
|
753
|
+
# DETECT_INVIS (FIGHT-004). Each recipient gets its own
|
|
754
|
+
# substituted string — `_broadcast_room` with a baked
|
|
755
|
+
# f-string would leak the victim's name.
|
|
704
756
|
if hasattr(victim, "room") and victim.room is not None:
|
|
705
|
-
|
|
706
|
-
victim
|
|
707
|
-
|
|
708
|
-
exclude=victim,
|
|
757
|
+
_broadcast_pos_change(
|
|
758
|
+
victim,
|
|
759
|
+
"{name} is mortally wounded, and will die soon, if not aided.",
|
|
709
760
|
)
|
|
710
761
|
return "You are mortally wounded, and will die soon, if not aided."
|
|
711
762
|
elif victim.position == Position.INCAP:
|
|
763
|
+
# mirroring ROM src/fight.c:845-846 — `act("$n is incapacitated
|
|
764
|
+
# and will slowly die, if not aided.", victim, NULL, NULL,
|
|
765
|
+
# TO_ROOM)`. PERS substitution per-listener (FIGHT-005).
|
|
712
766
|
if hasattr(victim, "room") and victim.room is not None:
|
|
713
|
-
|
|
714
|
-
victim
|
|
715
|
-
|
|
716
|
-
exclude=victim,
|
|
767
|
+
_broadcast_pos_change(
|
|
768
|
+
victim,
|
|
769
|
+
"{name} is incapacitated and will slowly die, if not aided.",
|
|
717
770
|
)
|
|
718
771
|
return "You are incapacitated and will slowly die, if not aided."
|
|
719
772
|
elif victim.position == Position.STUNNED:
|
|
773
|
+
# mirroring ROM src/fight.c:853-854 — `act("$n is stunned, but
|
|
774
|
+
# will probably recover.", victim, NULL, NULL, TO_ROOM)`. PERS
|
|
775
|
+
# substitution per-listener (FIGHT-006).
|
|
720
776
|
if hasattr(victim, "room") and victim.room is not None:
|
|
721
|
-
|
|
722
|
-
victim
|
|
723
|
-
|
|
724
|
-
exclude=victim,
|
|
777
|
+
_broadcast_pos_change(
|
|
778
|
+
victim,
|
|
779
|
+
"{name} is stunned, but will probably recover.",
|
|
725
780
|
)
|
|
726
781
|
return "You are stunned, but will probably recover."
|
|
727
782
|
elif victim.position == Position.DEAD:
|
|
783
|
+
# mirroring ROM src/fight.c:860 — `act("{R$n is DEAD!!{x",
|
|
784
|
+
# victim, 0, 0, TO_ROOM)`. PERS substitution per-listener
|
|
785
|
+
# (FIGHT-007), wrapped in ROM red colour codes ({R...{x) the
|
|
786
|
+
# ANSI translation layer consumes on websocket send, and ROM
|
|
787
|
+
# uses exactly two exclamation marks (not three).
|
|
728
788
|
if hasattr(victim, "room") and victim.room is not None:
|
|
729
|
-
|
|
730
|
-
|
|
789
|
+
_broadcast_pos_change(victim, "{{R{name} is DEAD!!{{x")
|
|
790
|
+
# mirroring ROM src/fight.c:861 — `send_to_char("{RYou have
|
|
791
|
+
# been KILLED!!{x\n\r\n\r", victim)`. ROM's two trailing
|
|
792
|
+
# \n\r pairs render as the death notice + one blank line.
|
|
793
|
+
# Python's protocol layer auto-appends one \r\n to every
|
|
794
|
+
# message, so the embedded trailing \n here gives the blank
|
|
795
|
+
# line on the wire (FIGHT-008). Red colour wrap {R...{x is
|
|
796
|
+
# consumed by mud/net/ansi.py on websocket send.
|
|
797
|
+
return "{RYou have been KILLED!!{x\n"
|
|
731
798
|
return ""
|
|
732
799
|
|
|
733
800
|
|
|
@@ -898,10 +965,18 @@ def _auto_sacrifice(attacker: Character, corpse) -> None:
|
|
|
898
965
|
_push_message(attacker, f"Mota gives you {silver_reward} silver coins for your sacrifice.")
|
|
899
966
|
|
|
900
967
|
corpse_name = getattr(corpse, "short_descr", None) or getattr(corpse, "name", "") or "corpse"
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
968
|
+
# mirroring ROM src/act_obj.c:1856 dispatched from
|
|
969
|
+
# src/fight.c:961-970 — `act("$n sacrifices $p to Mota.", ch,
|
|
970
|
+
# obj, NULL, TO_ROOM)`. PERS substitution on $n (attacker)
|
|
971
|
+
# per-listener (FIGHT-014). The helper's first arg is named
|
|
972
|
+
# `victim` for historical reasons but the function just
|
|
973
|
+
# PERS-renders that character for each room observer — works
|
|
974
|
+
# equally for an attacker. Corpse name passes through verbatim
|
|
975
|
+
# ($p is not can_see-gated).
|
|
976
|
+
_broadcast_pos_change(
|
|
977
|
+
attacker,
|
|
978
|
+
"{name} sacrifices {corpse} to Mota.",
|
|
979
|
+
corpse=corpse_name,
|
|
905
980
|
)
|
|
906
981
|
wiznet(
|
|
907
982
|
"$N sends up $p as a burnt offering.",
|
|
@@ -1493,10 +1568,15 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
|
|
|
1493
1568
|
if not saves_spell(level // 2, victim, DamageType.POISON):
|
|
1494
1569
|
_push_message(victim, "You feel poison coursing through your veins.")
|
|
1495
1570
|
if room is not None:
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1571
|
+
# mirroring ROM src/fight.c:614-615 — `act("$n is
|
|
1572
|
+
# poisoned by the venom on $p.", victim, wield, NULL,
|
|
1573
|
+
# TO_ROOM)`. PERS substitution on $n per-listener
|
|
1574
|
+
# (FIGHT-009). Weapon name passes verbatim ($p is not
|
|
1575
|
+
# can_see-gated).
|
|
1576
|
+
_broadcast_pos_change(
|
|
1577
|
+
victim,
|
|
1578
|
+
"{name} is poisoned by the venom on {weapon}.",
|
|
1579
|
+
weapon=weapon_name,
|
|
1500
1580
|
)
|
|
1501
1581
|
if hasattr(victim, "add_affect"):
|
|
1502
1582
|
victim.add_affect(AffectFlag.POISON)
|
|
@@ -1507,7 +1587,14 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
|
|
|
1507
1587
|
dam = rng_mm.number_range(1, weapon_level // 5 + 1)
|
|
1508
1588
|
_push_message(victim, f"You feel {weapon_name} drawing your life away.")
|
|
1509
1589
|
if room is not None:
|
|
1510
|
-
|
|
1590
|
+
# mirroring ROM src/fight.c:643 — `act("$p draws life
|
|
1591
|
+
# from $n.", victim, wield, NULL, TO_ROOM)`. PERS on $n
|
|
1592
|
+
# per-listener (FIGHT-010).
|
|
1593
|
+
_broadcast_pos_change(
|
|
1594
|
+
victim,
|
|
1595
|
+
"{weapon} draws life from {name}.",
|
|
1596
|
+
weapon=weapon_name,
|
|
1597
|
+
)
|
|
1511
1598
|
|
|
1512
1599
|
# Apply vampiric damage (additional negative damage) without extra messages
|
|
1513
1600
|
apply_damage(attacker, victim, dam, DamageType.NEGATIVE, show=False)
|
|
@@ -1528,7 +1615,14 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
|
|
|
1528
1615
|
dam = rng_mm.number_range(1, weapon_level // 4 + 1)
|
|
1529
1616
|
_push_message(victim, f"{weapon_name} sears your flesh.")
|
|
1530
1617
|
if room is not None:
|
|
1531
|
-
|
|
1618
|
+
# mirroring ROM src/fight.c:654 — `act("$n is burned by
|
|
1619
|
+
# $p.", victim, wield, NULL, TO_ROOM)`. PERS on $n
|
|
1620
|
+
# per-listener (FIGHT-011).
|
|
1621
|
+
_broadcast_pos_change(
|
|
1622
|
+
victim,
|
|
1623
|
+
"{name} is burned by {weapon}.",
|
|
1624
|
+
weapon=weapon_name,
|
|
1625
|
+
)
|
|
1532
1626
|
fire_effect(victim, weapon_level // 2, dam, SpellTarget.CHAR)
|
|
1533
1627
|
apply_damage(attacker, victim, dam, DamageType.FIRE, show=False)
|
|
1534
1628
|
messages.append(f"{weapon_name} sears your flesh.")
|
|
@@ -1538,7 +1632,17 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
|
|
|
1538
1632
|
dam = rng_mm.number_range(1, weapon_level // 6 + 2)
|
|
1539
1633
|
_push_message(victim, "The cold touch surrounds you with ice.")
|
|
1540
1634
|
if room is not None:
|
|
1541
|
-
|
|
1635
|
+
# mirroring ROM src/fight.c:663 — `act("$p freezes $n.",
|
|
1636
|
+
# victim, wield, NULL, TO_ROOM)`. ROM places the weapon
|
|
1637
|
+
# ($p) first and the victim ($n) as the object of
|
|
1638
|
+
# "freezes" — Python previously emitted "X is frozen by Y"
|
|
1639
|
+
# which inverts the subject/object (FIGHT-012). Fix both
|
|
1640
|
+
# the PERS gap and the wording in one pass.
|
|
1641
|
+
_broadcast_pos_change(
|
|
1642
|
+
victim,
|
|
1643
|
+
"{weapon} freezes {name}.",
|
|
1644
|
+
weapon=weapon_name,
|
|
1645
|
+
)
|
|
1542
1646
|
cold_effect(victim, weapon_level // 2, dam, SpellTarget.CHAR)
|
|
1543
1647
|
apply_damage(attacker, victim, dam, DamageType.COLD, show=False)
|
|
1544
1648
|
messages.append("The cold touch surrounds you with ice.")
|
|
@@ -1548,10 +1652,13 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
|
|
|
1548
1652
|
dam = rng_mm.number_range(1, weapon_level // 5 + 2)
|
|
1549
1653
|
_push_message(victim, "You are shocked by the weapon.")
|
|
1550
1654
|
if room is not None:
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1655
|
+
# mirroring ROM src/fight.c:673-674 — `act("$n is struck
|
|
1656
|
+
# by lightning from $p.", victim, wield, NULL, TO_ROOM)`.
|
|
1657
|
+
# PERS on $n per-listener (FIGHT-013).
|
|
1658
|
+
_broadcast_pos_change(
|
|
1659
|
+
victim,
|
|
1660
|
+
"{name} is struck by lightning from {weapon}.",
|
|
1661
|
+
weapon=weapon_name,
|
|
1555
1662
|
)
|
|
1556
1663
|
shock_effect(victim, weapon_level // 2, dam, SpellTarget.CHAR)
|
|
1557
1664
|
apply_damage(attacker, victim, dam, DamageType.LIGHTNING, show=False)
|
mud/combat/messages.py
CHANGED
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
|
|
7
7
|
from mud.math.c_compat import c_div
|
|
8
8
|
from mud.models.constants import ATTACK_TABLE, DamageType, Sex
|
|
9
|
+
from mud.world.vision import pers
|
|
9
10
|
|
|
10
11
|
TYPE_HIT = 1000
|
|
11
12
|
MAX_DAMAGE_MESSAGE = len(ATTACK_TABLE)
|
|
@@ -13,7 +14,19 @@ MAX_DAMAGE_MESSAGE = len(ATTACK_TABLE)
|
|
|
13
14
|
|
|
14
15
|
@dataclass(frozen=True)
|
|
15
16
|
class DamageMessages:
|
|
16
|
-
"""Container for ROM-style attacker/victim/room combat
|
|
17
|
+
"""Container for ROM-style attacker/victim/room combat templates.
|
|
18
|
+
|
|
19
|
+
Each field is a `.format()`-ready template containing `{attacker}`
|
|
20
|
+
and/or `{victim}` placeholders. Use `render_for(template,
|
|
21
|
+
attacker, victim, observer)` to substitute ROM `PERS()`-rendered
|
|
22
|
+
names per recipient — mirrors ROM's `act()` macro which evaluates
|
|
23
|
+
`PERS(ch, looker)` and `PERS(victim, looker)` independently for
|
|
24
|
+
each observer (DAMMSG-001/002/003).
|
|
25
|
+
|
|
26
|
+
ROM colour codes `{3...{x` / `{2...{x` / `{4...{x` are escaped as
|
|
27
|
+
`{{3}}` etc. in the template literals so `str.format()` leaves
|
|
28
|
+
them intact for the ANSI translation layer.
|
|
29
|
+
"""
|
|
17
30
|
|
|
18
31
|
attacker: str | None
|
|
19
32
|
victim: str | None
|
|
@@ -21,6 +34,28 @@ class DamageMessages:
|
|
|
21
34
|
self_inflicted: bool = False
|
|
22
35
|
|
|
23
36
|
|
|
37
|
+
def render_for(
|
|
38
|
+
template: str | None,
|
|
39
|
+
attacker: object,
|
|
40
|
+
victim: object,
|
|
41
|
+
observer: object | None,
|
|
42
|
+
) -> str | None:
|
|
43
|
+
"""Substitute `{attacker}` / `{victim}` placeholders through PERS.
|
|
44
|
+
|
|
45
|
+
Mirrors ROM's `act()` macro — `$n` resolves through `PERS(ch,
|
|
46
|
+
looker)` and `$N` through `PERS(victim, looker)`. The observer
|
|
47
|
+
is the recipient of the message; for TO_NOTVICT this is iterated
|
|
48
|
+
per room occupant, for TO_CHAR this is the attacker, for TO_VICT
|
|
49
|
+
this is the victim.
|
|
50
|
+
"""
|
|
51
|
+
if template is None:
|
|
52
|
+
return None
|
|
53
|
+
return template.format(
|
|
54
|
+
attacker=pers(attacker, observer),
|
|
55
|
+
victim=pers(victim, observer),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
24
59
|
# Severity tiers mirror src/fight.c:dam_message percent thresholds.
|
|
25
60
|
_DAMAGE_TIERS: tuple[tuple[int, str, str], ...] = (
|
|
26
61
|
(5, "scratch", "scratches"),
|
|
@@ -45,13 +80,6 @@ _DAMAGE_TIERS: tuple[tuple[int, str, str], ...] = (
|
|
|
45
80
|
)
|
|
46
81
|
|
|
47
82
|
|
|
48
|
-
def _safe_name(character: object) -> str:
|
|
49
|
-
name = getattr(character, "name", None)
|
|
50
|
-
if not name:
|
|
51
|
-
return "Someone"
|
|
52
|
-
return str(name)
|
|
53
|
-
|
|
54
|
-
|
|
55
83
|
def _reflexive_pronoun(character: object) -> str:
|
|
56
84
|
try:
|
|
57
85
|
sex = Sex(getattr(character, "sex", Sex.NONE))
|
|
@@ -127,56 +155,64 @@ def dam_message(
|
|
|
127
155
|
vs, vp, percent = _severity_terms(max(0, int(damage)), victim)
|
|
128
156
|
punct = "." if percent <= 45 else "!"
|
|
129
157
|
|
|
130
|
-
attacker_name = _safe_name(attacker)
|
|
131
|
-
victim_name = _safe_name(victim)
|
|
132
158
|
attack = _resolve_attack_noun(dt)
|
|
133
159
|
self_inflicted = attacker is victim
|
|
134
160
|
|
|
135
161
|
if attack is None and immune:
|
|
136
162
|
attack = "attack"
|
|
137
163
|
|
|
164
|
+
# Templates use `{{attacker}}` / `{{victim}}` placeholders that
|
|
165
|
+
# `render_for()` substitutes per-recipient through ROM PERS().
|
|
166
|
+
# ROM colour codes (`{3...{x` etc.) are doubled so str.format()
|
|
167
|
+
# leaves them intact (DAMMSG-001/002/003).
|
|
138
168
|
if int(percent) <= 0 and not immune:
|
|
139
169
|
# Mirror ROM miss output
|
|
140
170
|
if self_inflicted:
|
|
141
|
-
room_msg =
|
|
142
|
-
attacker_msg =
|
|
171
|
+
room_msg = "{{3{attacker} " + vp + " " + _reflexive_pronoun(attacker) + punct + "{{x"
|
|
172
|
+
attacker_msg = "{{2You " + vs + " yourself" + punct + "{{x"
|
|
143
173
|
return DamageMessages(attacker_msg, None, room_msg, True)
|
|
144
|
-
room_msg =
|
|
145
|
-
attacker_msg =
|
|
146
|
-
victim_msg =
|
|
174
|
+
room_msg = "{{3{attacker} " + vp + " {victim}" + punct + "{{x"
|
|
175
|
+
attacker_msg = "{{2You " + vs + " {victim}" + punct + "{{x"
|
|
176
|
+
victim_msg = "{{4{attacker} " + vp + " you" + punct + "{{x"
|
|
147
177
|
return DamageMessages(attacker_msg, victim_msg, room_msg, False)
|
|
148
178
|
|
|
149
179
|
if immune:
|
|
150
180
|
if self_inflicted:
|
|
151
181
|
poss = _possessive_pronoun(attacker)
|
|
152
|
-
room_msg =
|
|
153
|
-
attacker_msg = "{2Luckily, you are immune to that.{x"
|
|
182
|
+
room_msg = "{{3{attacker} is unaffected by " + poss + " own " + attack + ".{{x"
|
|
183
|
+
attacker_msg = "{{2Luckily, you are immune to that.{{x"
|
|
154
184
|
return DamageMessages(attacker_msg, None, room_msg, True)
|
|
155
|
-
room_msg =
|
|
156
|
-
attacker_msg =
|
|
157
|
-
victim_msg =
|
|
185
|
+
room_msg = "{{3{victim} is unaffected by {attacker}'s " + attack + "!{{x"
|
|
186
|
+
attacker_msg = "{{2{victim} is unaffected by your " + attack + "!{{x"
|
|
187
|
+
victim_msg = "{{4{attacker}'s " + attack + " is powerless against you.{{x"
|
|
158
188
|
return DamageMessages(attacker_msg, victim_msg, room_msg, False)
|
|
159
189
|
|
|
160
190
|
if attack is None:
|
|
161
191
|
if self_inflicted:
|
|
162
|
-
room_msg =
|
|
163
|
-
attacker_msg =
|
|
192
|
+
room_msg = "{{3{attacker} " + vp + " " + _reflexive_pronoun(attacker) + punct + "{{x"
|
|
193
|
+
attacker_msg = "{{2You " + vs + " yourself" + punct + "{{x"
|
|
164
194
|
return DamageMessages(attacker_msg, None, room_msg, True)
|
|
165
|
-
room_msg =
|
|
166
|
-
attacker_msg =
|
|
167
|
-
victim_msg =
|
|
195
|
+
room_msg = "{{3{attacker} " + vp + " {victim}" + punct + "{{x"
|
|
196
|
+
attacker_msg = "{{2You " + vs + " {victim}" + punct + "{{x"
|
|
197
|
+
victim_msg = "{{4{attacker} " + vp + " you" + punct + "{{x"
|
|
168
198
|
return DamageMessages(attacker_msg, victim_msg, room_msg, False)
|
|
169
199
|
|
|
170
200
|
if self_inflicted:
|
|
171
201
|
poss = _possessive_pronoun(attacker)
|
|
172
|
-
room_msg =
|
|
173
|
-
attacker_msg =
|
|
202
|
+
room_msg = "{{3{attacker}'s " + attack + " " + vp + " " + _reflexive_pronoun(attacker) + punct + "{{x"
|
|
203
|
+
attacker_msg = "{{2Your " + attack + " " + vp + " you" + punct + "{{x"
|
|
174
204
|
return DamageMessages(attacker_msg, None, room_msg, True)
|
|
175
205
|
|
|
176
|
-
room_msg =
|
|
177
|
-
attacker_msg =
|
|
178
|
-
victim_msg =
|
|
206
|
+
room_msg = "{{3{attacker}'s " + attack + " " + vp + " {victim}" + punct + "{{x"
|
|
207
|
+
attacker_msg = "{{2Your " + attack + " " + vp + " {victim}" + punct + "{{x"
|
|
208
|
+
victim_msg = "{{4{attacker}'s " + attack + " " + vp + " you" + punct + "{{x"
|
|
179
209
|
return DamageMessages(attacker_msg, victim_msg, room_msg, False)
|
|
180
210
|
|
|
181
211
|
|
|
182
|
-
__all__: tuple[str, ...] = (
|
|
212
|
+
__all__: tuple[str, ...] = (
|
|
213
|
+
"DamageMessages",
|
|
214
|
+
"TYPE_HIT",
|
|
215
|
+
"MAX_DAMAGE_MESSAGE",
|
|
216
|
+
"dam_message",
|
|
217
|
+
"render_for",
|
|
218
|
+
)
|
mud/commands/advancement.py
CHANGED
|
@@ -307,20 +307,26 @@ def do_train(char: Character, args: str) -> str:
|
|
|
307
307
|
# Show available training options (ROM C lines 1713-1745)
|
|
308
308
|
options = ["You can train:"]
|
|
309
309
|
|
|
310
|
-
# Check which stats can be trained (ROM C
|
|
311
|
-
#
|
|
312
|
-
#
|
|
310
|
+
# Check which stats can be trained (ROM C src/act_move.c:1716-1725).
|
|
311
|
+
# ROM reads `ch->perm_stat[STAT_*]`; QuickMUD stores the same list at
|
|
312
|
+
# `char.perm_stat`. There are no `perm_str`/`perm_int`/… attributes.
|
|
313
|
+
# get_max_train returns race_max + 4 (ROM src/handler.c:3027);
|
|
314
|
+
# the ROM standard is 18 base + 4 = 22.
|
|
313
315
|
max_stat = 22
|
|
316
|
+
perm_stat = getattr(char, "perm_stat", []) or []
|
|
314
317
|
|
|
315
|
-
|
|
318
|
+
def _stat(idx: int) -> int:
|
|
319
|
+
return perm_stat[idx] if idx < len(perm_stat) else 0
|
|
320
|
+
|
|
321
|
+
if _stat(0) < max_stat: # STAT_STR
|
|
316
322
|
options.append(" str")
|
|
317
|
-
if
|
|
323
|
+
if _stat(1) < max_stat: # STAT_INT
|
|
318
324
|
options.append(" int")
|
|
319
|
-
if
|
|
325
|
+
if _stat(2) < max_stat: # STAT_WIS
|
|
320
326
|
options.append(" wis")
|
|
321
|
-
if
|
|
327
|
+
if _stat(3) < max_stat: # STAT_DEX
|
|
322
328
|
options.append(" dex")
|
|
323
|
-
if
|
|
329
|
+
if _stat(4) < max_stat: # STAT_CON
|
|
324
330
|
options.append(" con")
|
|
325
331
|
options.append(" hp mana")
|
|
326
332
|
|