rom24-quickmud-python 2.5.2__py3-none-any.whl → 2.5.4__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 (46) hide show
  1. mud/__main__.py +1 -1
  2. mud/ai/__init__.py +14 -8
  3. mud/characters/follow.py +15 -1
  4. mud/combat/death.py +22 -26
  5. mud/combat/engine.py +57 -37
  6. mud/commands/admin_commands.py +2 -0
  7. mud/commands/advancement.py +10 -2
  8. mud/commands/affects.py +96 -45
  9. mud/commands/character.py +99 -53
  10. mud/commands/combat.py +10 -0
  11. mud/commands/communication.py +15 -4
  12. mud/commands/compare.py +59 -92
  13. mud/commands/equipment.py +153 -30
  14. mud/commands/imm_commands.py +92 -86
  15. mud/commands/info.py +328 -89
  16. mud/commands/info_extended.py +127 -67
  17. mud/commands/inspection.py +147 -9
  18. mud/commands/inventory.py +169 -9
  19. mud/commands/obj_manipulation.py +39 -3
  20. mud/commands/player_info.py +42 -40
  21. mud/commands/session.py +166 -24
  22. mud/commands/shop.py +3 -24
  23. mud/config.py +19 -1
  24. mud/game_loop.py +183 -11
  25. mud/handler.py +1424 -0
  26. mud/magic/effects.py +723 -34
  27. mud/mob_cmds.py +54 -10
  28. mud/models/character.py +178 -2
  29. mud/models/constants.py +8 -0
  30. mud/models/room.py +77 -7
  31. mud/persistence.py +327 -1
  32. mud/skills/handlers.py +0 -8
  33. mud/spawning/templates.py +73 -4
  34. mud/utils/rng_mm.py +15 -0
  35. mud/utils/text.py +50 -12
  36. mud/world/char_find.py +33 -35
  37. mud/world/look.py +156 -49
  38. mud/world/obj_find.py +60 -40
  39. mud/world/vision.py +17 -7
  40. mud/world/world_state.py +13 -2
  41. {rom24_quickmud_python-2.5.2.dist-info → rom24_quickmud_python-2.5.4.dist-info}/METADATA +13 -11
  42. {rom24_quickmud_python-2.5.2.dist-info → rom24_quickmud_python-2.5.4.dist-info}/RECORD +46 -45
  43. {rom24_quickmud_python-2.5.2.dist-info → rom24_quickmud_python-2.5.4.dist-info}/WHEEL +0 -0
  44. {rom24_quickmud_python-2.5.2.dist-info → rom24_quickmud_python-2.5.4.dist-info}/entry_points.txt +0 -0
  45. {rom24_quickmud_python-2.5.2.dist-info → rom24_quickmud_python-2.5.4.dist-info}/licenses/LICENSE +0 -0
  46. {rom24_quickmud_python-2.5.2.dist-info → rom24_quickmud_python-2.5.4.dist-info}/top_level.txt +0 -0
mud/__main__.py CHANGED
@@ -31,7 +31,7 @@ def loadtestuser():
31
31
 
32
32
 
33
33
  @cli.command()
34
- def socketserver(host: str = "0.0.0.0", port: int = 5000):
34
+ def socketserver(host: str = "0.0.0.0", port: int = 5001):
35
35
  """Start the telnet server."""
36
36
  asyncio.run(start_telnet(host=host, port=port))
37
37
 
mud/ai/__init__.py CHANGED
@@ -16,7 +16,8 @@ from mud.models.constants import (
16
16
  EX_CLOSED,
17
17
  )
18
18
  from mud.models.obj import ObjectData
19
- from mud.models.room import Exit, Room, room_registry
19
+ from mud.models.room import Exit, Room
20
+ from mud.registry import room_registry
20
21
  from mud.utils import rng_mm
21
22
  from mud.world.movement import move_character
22
23
 
@@ -209,14 +210,20 @@ def _room_contents(room: Room | None) -> Iterable[ObjectData]:
209
210
 
210
211
 
211
212
  def _take_object(mob: Character, obj: ObjectData) -> None:
212
- room = getattr(obj, "in_room", None) or getattr(mob, "room", None)
213
+ room = getattr(obj, "in_room", None) or getattr(obj, "location", None) or getattr(mob, "room", None)
213
214
  if room is not None:
214
215
  contents = getattr(room, "contents", None)
215
216
  if isinstance(contents, list) and obj in contents:
216
217
  contents.remove(obj)
217
- obj.in_room = None
218
- obj.in_obj = None
219
- obj.carried_by = mob
218
+
219
+ if hasattr(obj, "in_room"):
220
+ obj.in_room = None
221
+ if hasattr(obj, "location"):
222
+ obj.location = None
223
+ if hasattr(obj, "in_obj"):
224
+ obj.in_obj = None
225
+ if hasattr(obj, "carried_by"):
226
+ obj.carried_by = mob
220
227
 
221
228
  inventory = getattr(mob, "inventory", None)
222
229
  if isinstance(inventory, list) and obj not in inventory:
@@ -280,9 +287,8 @@ def _maybe_wander(mob: Character, room: Room) -> None:
280
287
  if rng_mm.number_bits(3) != 0:
281
288
  return
282
289
 
283
- door = rng_mm.number_bits(5)
284
- if door > int(Direction.DOWN):
285
- return
290
+ # ROM C mob_cmds.c:1274 uses number_door() for random direction
291
+ door = rng_mm.number_door()
286
292
 
287
293
  exit_obj = _valid_exit(room, door)
288
294
  if exit_obj is None:
mud/characters/follow.py CHANGED
@@ -67,4 +67,18 @@ def stop_follower(follower: "Character") -> None:
67
67
  follower.leader = None
68
68
 
69
69
 
70
- __all__ = ["add_follower", "stop_follower"]
70
+ def die_follower(char: "Character") -> None:
71
+ """Stop all followers when character dies.
72
+
73
+ ROM Reference: src/handler.c die_follower
74
+ Mirrors ROM behavior: when character dies, all their followers stop following.
75
+ """
76
+ from mud.models.character import character_registry
77
+
78
+ for follower in list(character_registry):
79
+ master = getattr(follower, "master", None)
80
+ if master is char:
81
+ stop_follower(follower)
82
+
83
+
84
+ __all__ = ["add_follower", "stop_follower", "die_follower"]
mud/combat/death.py CHANGED
@@ -145,7 +145,9 @@ def _is_floating_slot(slot: str | None, obj: Object) -> bool:
145
145
 
146
146
 
147
147
  def _format_corpse_labels(corpse: Object, name: str) -> None:
148
- short_template = getattr(corpse.prototype, "short_descr", None) or getattr(corpse, "short_descr", "the corpse of %s")
148
+ short_template = getattr(corpse.prototype, "short_descr", None) or getattr(
149
+ corpse, "short_descr", "the corpse of %s"
150
+ )
149
151
  desc_template = getattr(corpse.prototype, "description", None) or getattr(
150
152
  corpse, "description", "The corpse of %s is lying here."
151
153
  )
@@ -216,11 +218,7 @@ def _broadcast_neighbor_cry(victim: Character) -> None:
216
218
  if room is None:
217
219
  return
218
220
 
219
- message = (
220
- "You hear something's death cry."
221
- if getattr(victim, "is_npc", False)
222
- else "You hear someone's death cry."
223
- )
221
+ message = "You hear something's death cry." if getattr(victim, "is_npc", False) else "You hear someone's death cry."
224
222
 
225
223
  for exit_data in getattr(room, "exits", []) or []:
226
224
  if exit_data is None:
@@ -341,7 +339,7 @@ def death_cry(victim: Character) -> None:
341
339
  def _fallback_corpse(vnum: int, *, item_type: ItemType) -> Object:
342
340
  """Return a minimal corpse object when the real prototype is missing."""
343
341
 
344
- proto = ObjIndex(vnum=vnum, short_descr="a corpse", description="The corpse of someone lies here.")
342
+ proto = ObjIndex(vnum=vnum, short_descr="the corpse of %s", description="The corpse of %s is lying here.")
345
343
  proto.item_type = int(item_type)
346
344
  corpse = Object(instance_id=None, prototype=proto)
347
345
  corpse.item_type = int(item_type)
@@ -368,19 +366,6 @@ def _strip_inventory(victim: Character) -> list[tuple[Object, bool]]:
368
366
  return items
369
367
 
370
368
 
371
- def _set_corpse_coins(corpse: Object, gold: int, silver: int) -> None:
372
- """Persist coin totals on the corpse and mirror into ``value[0:2]``."""
373
-
374
- corpse.gold = gold
375
- corpse.silver = silver
376
- values = list(getattr(corpse, "value", []) or [])
377
- while len(values) < 2:
378
- values.append(0)
379
- values[0] = gold
380
- values[1] = silver
381
- corpse.value = values
382
-
383
-
384
369
  def _handle_corpse_item(
385
370
  corpse: Object,
386
371
  room,
@@ -445,9 +430,18 @@ def make_corpse(victim: Character) -> Object | None:
445
430
 
446
431
  gold = max(0, int(getattr(victim, "gold", 0) or 0))
447
432
  silver = max(0, int(getattr(victim, "silver", 0) or 0))
448
- _set_corpse_coins(corpse, gold, silver)
449
- victim.gold = max(0, int(getattr(victim, "gold", 0) or 0) - gold)
450
- victim.silver = max(0, int(getattr(victim, "silver", 0) or 0) - silver)
433
+
434
+ # ROM C fight.c:1473-1478 - Create money object inside corpse
435
+ if gold > 0 or silver > 0:
436
+ from mud.handler import create_money
437
+
438
+ money_obj = create_money(gold, silver)
439
+ if money_obj:
440
+ corpse.contained_items.append(money_obj)
441
+ money_obj.location = None # Inside corpse, not in room
442
+
443
+ victim.gold = 0
444
+ victim.silver = 0
451
445
 
452
446
  if not is_npc:
453
447
  _clear_player_flag(victim, PlayerFlag.CANLOOT)
@@ -557,16 +551,18 @@ def raw_kill(victim: Character) -> Object | None:
557
551
  """Handle character death by creating a corpse and removing the victim."""
558
552
 
559
553
  from mud.combat.engine import stop_fighting as _stop_fighting
560
-
554
+ from mud.characters.follow import die_follower
555
+
561
556
  # Trigger death mobprog handled in apply_damage before raw_kill
562
557
  # ROM Reference: src/fight.c:1136-1180 (mp_death_trigger called before raw_kill)
563
-
558
+
559
+ _nuke_pets(victim, room=getattr(victim, "room", None))
560
+ die_follower(victim)
564
561
  _stop_fighting(victim, True)
565
562
  death_cry(victim)
566
563
  corpse = make_corpse(victim)
567
564
 
568
565
  room = getattr(victim, "room", None)
569
- _nuke_pets(victim, room)
570
566
  if room is not None:
571
567
  room.remove_character(victim)
572
568
 
mud/combat/engine.py CHANGED
@@ -499,6 +499,20 @@ def apply_damage(
499
499
  if victim.position == Position.DEAD:
500
500
  return "Already dead."
501
501
 
502
+ # Set up fighting state BEFORE defense checks (ROM parity: src/fight.c:damage sets fighting before parry/dodge)
503
+ if victim != attacker:
504
+ if victim.position > Position.STUNNED:
505
+ if victim.fighting is None:
506
+ set_fighting(victim, attacker)
507
+ if getattr(victim, "is_npc", False):
508
+ mobprog.mp_kill_trigger(victim, attacker)
509
+ if getattr(victim, "timer", 0) <= 4:
510
+ victim.position = Position.FIGHTING
511
+
512
+ if victim.position > Position.STUNNED:
513
+ if attacker.fighting is None:
514
+ set_fighting(attacker, victim)
515
+
502
516
  # Check for parry, dodge, and shield block following C src/fight.c:damage() order
503
517
  # These are checked AFTER hit determination but BEFORE damage application
504
518
  # Order is critical: shield_block → parry → dodge (per ROM C src/fight.c:one_hit)
@@ -533,23 +547,6 @@ def apply_damage(
533
547
  message_bundle = dam_message(attacker, victim, damage, dt, immune)
534
548
  _dispatch_damage_messages(attacker, victim, message_bundle)
535
549
 
536
- # Set up fighting state if not already fighting
537
- if victim != attacker:
538
- # Victim starts fighting back if able
539
- if victim.position > Position.STUNNED:
540
- if victim.fighting is None:
541
- set_fighting(victim, attacker)
542
- if getattr(victim, "is_npc", False):
543
- mobprog.mp_kill_trigger(victim, attacker)
544
- # Update victim to fighting position if timer allows
545
- if getattr(victim, "timer", 0) <= 4:
546
- victim.position = Position.FIGHTING
547
-
548
- # Attacker starts fighting if not already
549
- if victim.position > Position.STUNNED:
550
- if attacker.fighting is None:
551
- set_fighting(attacker, victim)
552
-
553
550
  if damage <= 0:
554
551
  if message_bundle and message_bundle.attacker:
555
552
  return message_bundle.attacker
@@ -752,30 +749,53 @@ def _object_has_wear_flag(obj, flag: WearFlag) -> bool:
752
749
 
753
750
 
754
751
  def _transfer_corpse_coins(attacker: Character, corpse) -> bool:
755
- """Move coins from *corpse* to *attacker*, returning True when any moved."""
752
+ """Extract gold/silver from money objects and add to attacker's purse.
756
753
 
757
- try:
758
- gold = int(getattr(corpse, "gold", 0) or 0)
759
- except (TypeError, ValueError):
760
- gold = 0
761
- try:
762
- silver = int(getattr(corpse, "silver", 0) or 0)
763
- except (TypeError, ValueError):
764
- silver = 0
754
+ ROM Reference: src/fight.c auto_loot handles money extraction.
755
+ QuickMUD uses actual money objects (ItemType.MONEY) instead of corpse attributes.
756
+ Money objects may already be in attacker's inventory if AUTOLOOT moved them.
757
+ """
758
+ from mud.models.constants import ItemType
759
+
760
+ contained = list(getattr(corpse, "contained_items", []) or [])
761
+ attacker_inv = list(getattr(attacker, "inventory", []) or [])
765
762
 
766
- if gold == 0 and silver == 0:
763
+ money_in_corpse = [obj for obj in contained if getattr(obj, "item_type", None) == int(ItemType.MONEY)]
764
+ money_in_inventory = [obj for obj in attacker_inv if getattr(obj, "item_type", None) == int(ItemType.MONEY)]
765
+
766
+ all_money_objects = money_in_corpse + money_in_inventory
767
+
768
+ if not all_money_objects:
767
769
  return False
768
770
 
769
- attacker.gold = int(getattr(attacker, "gold", 0) or 0) + gold
770
- attacker.silver = int(getattr(attacker, "silver", 0) or 0) + silver
771
- corpse.gold = 0
772
- corpse.silver = 0
773
- values = list(getattr(corpse, "value", []) or [])
774
- if len(values) < 2:
775
- values.extend([0] * (2 - len(values)))
776
- values[0] = 0
777
- values[1] = 0
778
- corpse.value = values
771
+ total_gold = 0
772
+ total_silver = 0
773
+
774
+ for money_obj in all_money_objects:
775
+ values = list(getattr(money_obj, "value", [0, 0]) or [0, 0])
776
+ while len(values) < 2:
777
+ values.append(0)
778
+
779
+ silver = int(values[0] or 0)
780
+ gold = int(values[1] or 0)
781
+
782
+ total_silver += silver
783
+ total_gold += gold
784
+
785
+ if money_obj in money_in_corpse:
786
+ try:
787
+ corpse.contained_items.remove(money_obj)
788
+ except (AttributeError, ValueError):
789
+ pass
790
+ else:
791
+ try:
792
+ attacker.remove_object(money_obj)
793
+ except (AttributeError, ValueError):
794
+ pass
795
+
796
+ attacker.gold = int(getattr(attacker, "gold", 0) or 0) + total_gold
797
+ attacker.silver = int(getattr(attacker, "silver", 0) or 0) + total_silver
798
+
779
799
  return True
780
800
 
781
801
 
@@ -34,6 +34,8 @@ def cmd_who(char: Character, args: str) -> str:
34
34
 
35
35
 
36
36
  def cmd_teleport(char: Character, args: str) -> str:
37
+ if char.level < 52:
38
+ return "You don't have permission to use this command."
37
39
  if not args.isdigit() or int(args) not in room_registry:
38
40
  return "Invalid room."
39
41
  target = room_registry[int(args)]
@@ -188,9 +188,17 @@ def do_practice(char: Character, args: str) -> str:
188
188
  new_value = min(adept, current + increment)
189
189
  char.skills[skill_key] = new_value
190
190
 
191
+ # ROM C parity: Send messages to both char and room (src/act_info.c:2767-2777)
191
192
  if new_value >= adept:
192
- return f"You are now learned at {skill.name}."
193
- return f"You practice {skill.name}."
193
+ char.messages.append(f"You are now learned at {skill.name}.")
194
+ if char.room:
195
+ char.room.broadcast(f"{char.name} is now learned at {skill.name}.", exclude=char)
196
+ else:
197
+ char.messages.append(f"You practice {skill.name}.")
198
+ if char.room:
199
+ char.room.broadcast(f"{char.name} practices {skill.name}.", exclude=char)
200
+
201
+ return ""
194
202
 
195
203
 
196
204
  def do_train(char: Character, args: str) -> str:
mud/commands/affects.py CHANGED
@@ -3,6 +3,7 @@ Affects command - show active spell/affect effects on character.
3
3
 
4
4
  ROM Reference: src/act_info.c do_affects (lines 2300-2400)
5
5
  """
6
+
6
7
  from __future__ import annotations
7
8
 
8
9
  from mud.models.character import Character
@@ -43,56 +44,106 @@ _AFFECT_NAMES = {
43
44
  }
44
45
 
45
46
 
47
+ def affect_loc_name(location: int) -> str:
48
+ """
49
+ Return ASCII name of an affect location.
50
+
51
+ ROM Reference: src/handler.c affect_loc_name (lines 2718-2775)
52
+
53
+ Args:
54
+ location: APPLY_* constant (0-25)
55
+
56
+ Returns:
57
+ Human-readable location name (e.g., "strength", "armor class", "hit roll")
58
+ """
59
+ # ROM C APPLY_* constants (src/merc.h lines 1205-1231)
60
+ APPLY_NAMES = {
61
+ 0: "none", # APPLY_NONE
62
+ 1: "strength", # APPLY_STR
63
+ 2: "dexterity", # APPLY_DEX
64
+ 3: "intelligence", # APPLY_INT
65
+ 4: "wisdom", # APPLY_WIS
66
+ 5: "constitution", # APPLY_CON
67
+ 6: "sex", # APPLY_SEX
68
+ 7: "class", # APPLY_CLASS
69
+ 8: "level", # APPLY_LEVEL
70
+ 9: "age", # APPLY_AGE
71
+ 10: "height", # APPLY_HEIGHT (not shown in ROM affect_loc_name)
72
+ 11: "weight", # APPLY_WEIGHT (not shown in ROM affect_loc_name)
73
+ 12: "mana", # APPLY_MANA
74
+ 13: "hp", # APPLY_HIT
75
+ 14: "moves", # APPLY_MOVE
76
+ 15: "gold", # APPLY_GOLD
77
+ 16: "experience", # APPLY_EXP
78
+ 17: "armor class", # APPLY_AC
79
+ 18: "hit roll", # APPLY_HITROLL
80
+ 19: "damage roll", # APPLY_DAMROLL
81
+ 20: "saves", # APPLY_SAVES / APPLY_SAVING_PARA
82
+ 21: "save vs rod", # APPLY_SAVING_ROD
83
+ 22: "save vs petrification", # APPLY_SAVING_PETRI
84
+ 23: "save vs breath", # APPLY_SAVING_BREATH
85
+ 24: "save vs spell", # APPLY_SAVING_SPELL
86
+ 25: "none", # APPLY_SPELL_AFFECT (returns "none" in ROM C)
87
+ }
88
+
89
+ return APPLY_NAMES.get(location, "(unknown)")
90
+
91
+
46
92
  def do_affects(char: Character, args: str) -> str:
47
93
  """
48
94
  Display active affects on the character.
49
-
50
- ROM Reference: src/act_info.c do_affects (lines 2300-2400)
51
-
95
+
96
+ ROM Reference: src/act_info.c do_affects (lines 1714-1755)
97
+
52
98
  Usage: affects
99
+
100
+ Behavior:
101
+ - Level <20: Shows simple format (spell name only)
102
+ - Level 20+: Shows detailed format (modifier, location, duration)
103
+ - Stacked affects (same spell, multiple modifiers): Indented continuation lines
53
104
  """
54
- lines = []
55
-
56
- # Check spell_effects (detailed spell effects with duration)
57
- spell_effects = getattr(char, "spell_effects", {})
58
- if spell_effects:
59
- lines.append("You are affected by the following spells:")
60
- for spell_name, effect in spell_effects.items():
61
- duration = getattr(effect, "duration", -1)
62
- if duration < 0:
63
- lines.append(f" {spell_name}: permanent")
105
+ # Primary ROM C behavior: iterate ch.affected list (AFFECT_DATA structures)
106
+ affected = getattr(char, "affected", [])
107
+
108
+ if not affected:
109
+ return "You are not affected by any spells."
110
+
111
+ lines = ["You are affected by the following spells:"]
112
+ paf_last = None
113
+
114
+ for paf in affected:
115
+ # Deduplication: check if same spell as previous affect
116
+ if paf_last and paf.type == paf_last.type:
117
+ if char.level >= 20:
118
+ # Level 20+: Show duplicate affects with indentation (22 spaces + ": ")
119
+ buf = " " * 22 + ": "
64
120
  else:
65
- lines.append(f" {spell_name}: {duration} hours remaining")
66
-
67
- # Check affect bitvector for built-in affects
68
- affected_by = getattr(char, "affected_by", 0)
69
- if affected_by:
70
- if not spell_effects:
71
- lines.append("You are affected by:")
121
+ # Level <20: Skip duplicate spells entirely
122
+ continue
72
123
  else:
73
- lines.append("\nYou also have these affects:")
74
-
75
- for flag, name in _AFFECT_NAMES.items():
76
- if affected_by & flag:
77
- # Check if this isn't already shown in spell_effects
78
- if name not in spell_effects:
79
- lines.append(f" {name}")
80
-
81
- # Check for special conditions
82
- if getattr(char, "position", 0) == 0: # DEAD
83
- lines.append("You are DEAD.")
84
-
85
- # Check for hunger/thirst (if applicable)
86
- pcdata = getattr(char, "pcdata", None)
87
- if pcdata:
88
- hunger = getattr(pcdata, "condition", [0, 0, 0, 0])
89
- if len(hunger) >= 2:
90
- if hunger[0] == 0: # COND_FULL
91
- lines.append("You are hungry.")
92
- if hunger[1] == 0: # COND_THIRST
93
- lines.append("You are thirsty.")
94
-
95
- if not lines:
96
- return "You are not affected by any spells."
97
-
124
+ # New spell: show spell name (left-aligned, 15 chars)
125
+ # TODO: Replace with proper skill_table[paf.type].name lookup when SN mapping is available
126
+ # For now, assume paf.type is already a skill name string (temporary until spell system updated)
127
+ spell_name = str(paf.type) if paf.type else "(unknown)"
128
+
129
+ buf = f"Spell: {spell_name:15s}"
130
+
131
+ # Level 20+: Show detailed modifier information
132
+ if char.level >= 20:
133
+ location_name = affect_loc_name(paf.location)
134
+
135
+ # ROM C line 1737: uses raw %d (no explicit + sign)
136
+ modifier_str = str(paf.modifier)
137
+
138
+ if paf.duration == -1:
139
+ duration_str = "permanently"
140
+ else:
141
+ duration_str = f"for {paf.duration} hours"
142
+
143
+ # ROM C line 1736: ": modifies..." (colon prefix)
144
+ buf += f": modifies {location_name} by {modifier_str} {duration_str}"
145
+
146
+ lines.append(buf)
147
+ paf_last = paf
148
+
98
149
  return "\n".join(lines)