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.
Files changed (45) hide show
  1. mud/account/account_manager.py +10 -4
  2. mud/affects/engine.py +4 -0
  3. mud/ai/__init__.py +9 -11
  4. mud/combat/death.py +3 -1
  5. mud/combat/engine.py +157 -50
  6. mud/combat/messages.py +67 -31
  7. mud/commands/advancement.py +14 -8
  8. mud/commands/auto_settings.py +32 -3
  9. mud/commands/combat.py +101 -56
  10. mud/commands/communication.py +103 -17
  11. mud/commands/dispatcher.py +15 -2
  12. mud/commands/imm_emote.py +8 -3
  13. mud/commands/info.py +10 -6
  14. mud/commands/info_extended.py +88 -51
  15. mud/commands/inventory.py +5 -2
  16. mud/commands/misc_info.py +155 -146
  17. mud/commands/session.py +36 -33
  18. mud/game_loop.py +75 -37
  19. mud/handler.py +26 -21
  20. mud/loaders/base_loader.py +2 -4
  21. mud/loaders/json_loader.py +9 -5
  22. mud/loaders/obj_loader.py +9 -5
  23. mud/loaders/room_loader.py +21 -2
  24. mud/mob_cmds.py +3 -9
  25. mud/models/__init__.py +1 -2
  26. mud/models/character.py +20 -12
  27. mud/models/obj.py +11 -49
  28. mud/models/object.py +63 -2
  29. mud/models/room.py +14 -1
  30. mud/music/__init__.py +5 -4
  31. mud/net/connection.py +155 -88
  32. mud/network/websocket_stream.py +2 -1
  33. mud/rom_api.py +18 -5
  34. mud/scripts/convert_are_to_json.py +1 -1
  35. mud/skills/handlers.py +51 -57
  36. mud/spawning/obj_spawner.py +6 -1
  37. mud/spawning/templates.py +0 -1
  38. mud/world/look.py +18 -29
  39. mud/world/vision.py +31 -0
  40. {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/METADATA +17 -16
  41. {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/RECORD +45 -45
  42. {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/WHEEL +0 -0
  43. {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/entry_points.txt +0 -0
  44. {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/licenses/LICENSE +0 -0
  45. {rom24_quickmud_python-2.8.21.dist-info → rom24_quickmud_python-2.9.2.dist-info}/top_level.txt +0 -0
@@ -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
- # Use identity check to avoid triggering dataclass __eq__ on deep
61
- # object graphs (inventory/equipment items cause recursion with list `in`)
62
- if not any(c is char for c in character_registry):
63
- character_registry.append(char) # INV-003
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.obj import ObjectData
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: ObjectData) -> bool:
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[ObjectData]:
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: ObjectData) -> None:
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: ObjectData | None = None
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
- setattr(mob, "gold", gold)
304
- setattr(mob, "silver", silver)
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
- money_obj.location = None # Inside corpse, not in room
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.net.protocol import broadcast_room as _broadcast_room
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 _dispatch_damage_messages(
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
- _push_message(attacker, messages.attacker)
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
- _push_message(victim, messages.victim)
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
- if messages.self_inflicted:
252
- _broadcast_room(room, messages.room, exclude=victim)
253
- return
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
- return message_bundle.attacker
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
- return message_bundle.attacker
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
- _broadcast_room(
706
- victim.room,
707
- f"{victim.name} is mortally wounded, and will die soon, if not aided.",
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
- _broadcast_room(
714
- victim.room,
715
- f"{victim.name} is incapacitated and will slowly die, if not aided.",
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
- _broadcast_room(
722
- victim.room,
723
- f"{victim.name} is stunned, but will probably recover.",
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
- _broadcast_room(victim.room, f"{victim.name} is DEAD!!!", exclude=victim)
730
- return "You have been KILLED!!"
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
- _broadcast_room(
902
- room,
903
- expand_placeholders("$n sacrifices $N to Mota.", attacker, SimpleNamespace(name=corpse_name)),
904
- exclude=attacker,
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
- _broadcast_room(
1497
- room,
1498
- f"{victim.name} is poisoned by the venom on {weapon_name}.",
1499
- exclude=victim,
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
- _broadcast_room(room, f"{weapon_name} draws life from {victim.name}.", exclude=victim)
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
- _broadcast_room(room, f"{victim.name} is burned by {weapon_name}.", exclude=victim)
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
- _broadcast_room(room, f"{victim.name} is frozen by {weapon_name}.", exclude=victim)
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
- _broadcast_room(
1552
- room,
1553
- f"{victim.name} is struck by lightning from {weapon_name}.",
1554
- exclude=victim,
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 strings."""
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 = f"{{3{attacker_name} {vp} {_reflexive_pronoun(attacker)}{punct}{{x"
142
- attacker_msg = f"{{2You {vs} yourself{punct}{{x"
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 = f"{{3{attacker_name} {vp} {victim_name}{punct}{{x"
145
- attacker_msg = f"{{2You {vs} {victim_name}{punct}{{x"
146
- victim_msg = f"{{4{attacker_name} {vp} you{punct}{{x"
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 = f"{{3{attacker_name} is unaffected by {poss} own {attack}.{{x"
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 = f"{{3{victim_name} is unaffected by {attacker_name}'s {attack}!{{x"
156
- attacker_msg = f"{{2{victim_name} is unaffected by your {attack}!{{x"
157
- victim_msg = f"{{4{attacker_name}'s {attack} is powerless against you.{{x"
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 = f"{{3{attacker_name} {vp} {_reflexive_pronoun(attacker)}{punct}{{x"
163
- attacker_msg = f"{{2You {vs} yourself{punct}{{x"
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 = f"{{3{attacker_name} {vp} {victim_name}{punct}{{x"
166
- attacker_msg = f"{{2You {vs} {victim_name}{punct}{{x"
167
- victim_msg = f"{{4{attacker_name} {vp} you{punct}{{x"
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 = f"{{3{attacker_name}'s {attack} {vp} {_reflexive_pronoun(attacker)}{punct}{{x"
173
- attacker_msg = f"{{2Your {attack} {vp} you{punct}{{x"
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 = f"{{3{attacker_name}'s {attack} {vp} {victim_name}{punct}{{x"
177
- attacker_msg = f"{{2Your {attack} {vp} {victim_name}{punct}{{x"
178
- victim_msg = f"{{4{attacker_name}'s {attack} {vp} you{punct}{{x"
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, ...] = ("DamageMessages", "TYPE_HIT", "MAX_DAMAGE_MESSAGE", "dam_message")
212
+ __all__: tuple[str, ...] = (
213
+ "DamageMessages",
214
+ "TYPE_HIT",
215
+ "MAX_DAMAGE_MESSAGE",
216
+ "dam_message",
217
+ "render_for",
218
+ )
@@ -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 lines 1716-1725)
311
- # get_max_train returns race_max + 4 (ROM C src/handler.c:3027)
312
- # For now, use 18 + 4 = 22 as max (ROM standard)
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
- if char.perm_str < max_stat:
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 char.perm_int < max_stat:
323
+ if _stat(1) < max_stat: # STAT_INT
318
324
  options.append(" int")
319
- if char.perm_wis < max_stat:
325
+ if _stat(2) < max_stat: # STAT_WIS
320
326
  options.append(" wis")
321
- if char.perm_dex < max_stat:
327
+ if _stat(3) < max_stat: # STAT_DEX
322
328
  options.append(" dex")
323
- if char.perm_con < max_stat:
329
+ if _stat(4) < max_stat: # STAT_CON
324
330
  options.append(" con")
325
331
  options.append(" hp mana")
326
332