rom24-quickmud-python 2.13.31__py3-none-any.whl → 2.14.211__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 (80) hide show
  1. mud/account/__init__.py +2 -0
  2. mud/account/account_manager.py +36 -0
  3. mud/account/account_service.py +13 -0
  4. mud/ai/__init__.py +36 -19
  5. mud/ai/aggressive.py +19 -6
  6. mud/characters/follow.py +13 -17
  7. mud/combat/assist.py +33 -10
  8. mud/combat/death.py +183 -89
  9. mud/combat/engine.py +83 -104
  10. mud/combat/messages.py +4 -3
  11. mud/combat/safety.py +181 -52
  12. mud/commands/advancement.py +21 -4
  13. mud/commands/build.py +19 -26
  14. mud/commands/character.py +5 -2
  15. mud/commands/combat.py +281 -124
  16. mud/commands/communication.py +122 -60
  17. mud/commands/compare.py +29 -12
  18. mud/commands/consider.py +19 -5
  19. mud/commands/consumption.py +40 -19
  20. mud/commands/dispatcher.py +97 -64
  21. mud/commands/doors.py +21 -7
  22. mud/commands/equipment.py +64 -47
  23. mud/commands/give.py +32 -22
  24. mud/commands/group_commands.py +98 -125
  25. mud/commands/healer.py +29 -3
  26. mud/commands/imm_commands.py +48 -67
  27. mud/commands/imm_emote.py +6 -10
  28. mud/commands/imm_load.py +16 -17
  29. mud/commands/imm_search.py +124 -74
  30. mud/commands/imm_server.py +45 -25
  31. mud/commands/imm_set.py +13 -18
  32. mud/commands/info.py +11 -15
  33. mud/commands/info_extended.py +29 -11
  34. mud/commands/inspection.py +6 -5
  35. mud/commands/inventory.py +10 -5
  36. mud/commands/magic_items.py +15 -7
  37. mud/commands/misc_info.py +6 -14
  38. mud/commands/misc_player.py +22 -17
  39. mud/commands/murder.py +50 -7
  40. mud/commands/obj_manipulation.py +7 -6
  41. mud/commands/player_config.py +33 -52
  42. mud/commands/remaining_rom.py +256 -73
  43. mud/commands/session.py +65 -71
  44. mud/commands/shop.py +63 -20
  45. mud/commands/socials.py +7 -0
  46. mud/commands/thief_skills.py +16 -8
  47. mud/game_loop.py +265 -103
  48. mud/groups/xp.py +7 -8
  49. mud/handler.py +61 -10
  50. mud/loaders/json_loader.py +6 -12
  51. mud/loaders/shop_loader.py +10 -1
  52. mud/magic/effects.py +69 -19
  53. mud/mob_cmds.py +28 -20
  54. mud/mobprog.py +21 -7
  55. mud/models/character.py +55 -22
  56. mud/models/room.py +2 -1
  57. mud/models/skill.py +9 -0
  58. mud/net/connection.py +294 -175
  59. mud/net/protocol.py +19 -3
  60. mud/registry.py +4 -0
  61. mud/scripts/convert_skills_to_json.py +5 -0
  62. mud/skills/handlers.py +332 -284
  63. mud/skills/metadata.py +45 -0
  64. mud/skills/registry.py +26 -23
  65. mud/spawning/reset_handler.py +46 -32
  66. mud/spawning/templates.py +9 -4
  67. mud/spec_funs.py +81 -36
  68. mud/utils/act.py +7 -0
  69. mud/utils/messaging.py +66 -0
  70. mud/world/char_find.py +6 -1
  71. mud/world/look.py +127 -52
  72. mud/world/movement.py +18 -4
  73. mud/world/vision.py +10 -0
  74. mud/world/world_state.py +10 -1
  75. {rom24_quickmud_python-2.13.31.dist-info → rom24_quickmud_python-2.14.211.dist-info}/METADATA +30 -19
  76. {rom24_quickmud_python-2.13.31.dist-info → rom24_quickmud_python-2.14.211.dist-info}/RECORD +80 -80
  77. {rom24_quickmud_python-2.13.31.dist-info → rom24_quickmud_python-2.14.211.dist-info}/WHEEL +0 -0
  78. {rom24_quickmud_python-2.13.31.dist-info → rom24_quickmud_python-2.14.211.dist-info}/entry_points.txt +0 -0
  79. {rom24_quickmud_python-2.13.31.dist-info → rom24_quickmud_python-2.14.211.dist-info}/licenses/LICENSE +0 -0
  80. {rom24_quickmud_python-2.13.31.dist-info → rom24_quickmud_python-2.14.211.dist-info}/top_level.txt +0 -0
mud/account/__init__.py CHANGED
@@ -32,6 +32,7 @@ from .account_service import (
32
32
  lookup_creation_race,
33
33
  lookup_hometown,
34
34
  lookup_weapon_choice,
35
+ mark_character_active,
35
36
  release_account,
36
37
  release_character,
37
38
  roll_creation_stats,
@@ -67,6 +68,7 @@ __all__ = [
67
68
  "sanitize_account_name",
68
69
  "clear_active_accounts",
69
70
  "is_account_active",
71
+ "mark_character_active",
70
72
  "release_account",
71
73
  "release_character",
72
74
  "LoginFailureReason",
@@ -76,6 +76,42 @@ def load_character(char_name: str, _ignored: str | None = None) -> Character | N
76
76
  session.close()
77
77
 
78
78
 
79
+ def delete_character(char_name: str) -> bool:
80
+ """Permanently delete a character from the canonical store (DB row).
81
+
82
+ DB-canonical (INV-008): the player's gameplay state and auth both live in
83
+ the ``characters`` DB row, so deletion means removing that row — NOT
84
+ unlinking a pfile (there is no JSON pfile path). Also drops any matching
85
+ runtime ``Character`` from ``character_registry`` so the name cannot be
86
+ referenced after deletion (INV-003 complement).
87
+
88
+ ROM Reference: src/act_comm.c do_delete (lines 54-93) — ROM ``do_quit``s the
89
+ character (which saves the pfile) and then ``unlink(strsave)`` removes it.
90
+ The DB-row delete here is the faithful equivalent of that ``unlink``.
91
+
92
+ Returns True if a row was deleted, False if no row existed.
93
+ """
94
+ # Drop any live runtime instance(s) by name first (idempotent).
95
+ for prior in [c for c in character_registry if getattr(c, "name", None) == char_name]:
96
+ character_registry.remove(prior)
97
+
98
+ session = None
99
+ try:
100
+ session = SessionLocal()
101
+ db_char = session.query(DBCharacter).filter(DBCharacter.name == char_name).first()
102
+ if db_char is None:
103
+ return False
104
+ session.delete(db_char)
105
+ session.commit()
106
+ return True
107
+ except Exception as e:
108
+ print(f"[ERROR] delete_character failed for {char_name}: {e}")
109
+ return False
110
+ finally:
111
+ if session:
112
+ session.close()
113
+
114
+
79
115
  def save_character(character: Character) -> None:
80
116
  """Persist ``character`` to the DB row (DB-canonical path).
81
117
 
@@ -824,6 +824,19 @@ def _mark_character_active(name: str) -> None:
824
824
  _active_characters.add(_normalize(name))
825
825
 
826
826
 
827
+ def mark_character_active(name: str) -> None:
828
+ """Set the active-session marker for this character.
829
+
830
+ Public counterpart to :func:`release_character`. ``login_with_host`` marks
831
+ returning logins internally; the INV-051 deferred-persistence new-character
832
+ path needs this entry point because it no longer routes through
833
+ ``login_with_host`` (it persists via ``create_character`` at creation end),
834
+ so a freshly-created in-game character must still be flagged active for the
835
+ name-phase duplicate-login check (``connection.py`` ``is_account_active``).
836
+ """
837
+ _mark_character_active(name)
838
+
839
+
827
840
  def release_character(name: str) -> None:
828
841
  """Clear the active-session marker for this character."""
829
842
  _active_characters.discard(_normalize(name))
mud/ai/__init__.py CHANGED
@@ -100,18 +100,6 @@ def _find_character(name: str) -> Character | None:
100
100
  return None
101
101
 
102
102
 
103
- def _broadcast_room(room: Room, message: str, exclude: object | None = None) -> None:
104
- if hasattr(room, "broadcast"):
105
- room.broadcast(message, exclude=exclude)
106
- return
107
- for occupant in getattr(room, "people", []) or []:
108
- if occupant is exclude:
109
- continue
110
- messages = getattr(occupant, "messages", None)
111
- if isinstance(messages, list):
112
- messages.append(message)
113
-
114
-
115
103
  def _can_loot(mob: Character, obj: Object) -> bool:
116
104
  if getattr(mob, "is_admin", False):
117
105
  return True
@@ -187,11 +175,13 @@ def _take_object(mob: Character, obj: Object) -> None:
187
175
  if isinstance(inventory, list) and obj not in inventory:
188
176
  inventory.insert(0, obj)
189
177
 
178
+ # mirroring ROM src/update.c:491 — act("$n gets $p.", ch, obj_best, NULL, TO_ROOM)
179
+ # act_to_room handles per-recipient formatting + TRIG_ACT dispatch (src/comm.c:2384).
190
180
  room = getattr(mob, "room", None)
191
- if room is not None and hasattr(room, "broadcast"):
192
- mob_name = getattr(mob, "short_descr", None) or getattr(mob, "name", None) or "Someone"
193
- obj_name = getattr(obj, "short_descr", None) or getattr(obj, "name", None) or "something"
194
- room.broadcast(f"{mob_name} gets {obj_name}.", exclude=mob)
181
+ if room is not None:
182
+ from mud.utils.act import act_to_room
183
+
184
+ act_to_room(room, "$n gets $p.", mob, arg1=obj, exclude=mob)
195
185
 
196
186
 
197
187
  def _maybe_scavenge(mob: Character, room: Room) -> None:
@@ -239,8 +229,15 @@ def _maybe_wander(mob: Character, room: Room) -> None:
239
229
  if rng_mm.number_bits(3) != 0:
240
230
  return
241
231
 
242
- # ROM C mob_cmds.c:1274 uses number_door() for random direction
243
- door = rng_mm.number_door()
232
+ # mirroring ROM src/update.c:498 wander draws a SINGLE 5-bit direction
233
+ # `(door = number_bits(5)) <= 5` and aborts the whole wander when the roll
234
+ # exceeds 5 (so a mob only wanders on 6/32 of otherwise-eligible ticks).
235
+ # NOT number_door() (the do_flee/do_mpflee primitive, src/db.c:3541), which
236
+ # re-rolls with a 3-bit mask until ≤5 and therefore always returns a valid
237
+ # door — that over-wanders ~5× and desyncs the shared Mitchell-Moore stream.
238
+ door = rng_mm.number_bits(5)
239
+ if door > 5:
240
+ return
244
241
 
245
242
  exit_obj = _valid_exit(room, door)
246
243
  if exit_obj is None:
@@ -275,7 +272,16 @@ def _maybe_wander(mob: Character, room: Room) -> None:
275
272
  def mobile_update() -> None:
276
273
  """Mirror ROM ``mobile_update`` scavenging, wandering, and mobprog triggers."""
277
274
 
278
- for mob in list(character_registry):
275
+ # mirroring ROM src/update.c:416 — mobile_update walks ``char_list``, which
276
+ # src/db.c:2256-2257 (create_mobile) head-inserts, so ROM visits the NEWEST
277
+ # mob first. ``character_registry`` is append-order, so iterate it reversed
278
+ # — load-bearing for the shared RNG draw order (shop-wealth rolls, scavenger
279
+ # 1/64, wander door rolls), like violence_tick/char_update/obj_update. GL-042.
280
+ for mob in list(reversed(character_registry)):
281
+ # mirroring ROM extract_char re-linking — a char removed from char_list
282
+ # mid-tick is never revisited in ROM's walk; skip extracted mobs. GL-042.
283
+ if mob not in character_registry:
284
+ continue
279
285
  if not getattr(mob, "is_npc", False):
280
286
  continue
281
287
  room = getattr(mob, "room", None)
@@ -289,6 +295,17 @@ def mobile_update() -> None:
289
295
  if not _mob_has_act_flag(mob, ActFlag.UPDATE_ALWAYS):
290
296
  continue
291
297
 
298
+ # mirroring ROM src/update.c:425-431 — the special procedure runs INSIDE
299
+ # the per-mob loop, after the charm/empty gates and BEFORE shop-gold,
300
+ # triggers, scavenge, and wander. A TRUE result `continue`s, skipping the
301
+ # rest of this mob's tick. Dispatched here (not as a separate
302
+ # run_npc_specs pass) so the gates and suppression match ROM exactly
303
+ # and the spec's RNG draws interleave per-mob with scavenge/wander. INV-049.
304
+ from mud.spec_funs import run_spec_fun
305
+
306
+ if run_spec_fun(mob):
307
+ continue
308
+
292
309
  shop, wealth = _resolve_shop_info(mob)
293
310
  if shop is not None and wealth > 0:
294
311
  try:
mud/ai/aggressive.py CHANGED
@@ -54,7 +54,17 @@ def _eligible_victims(ch: Character, occupants: Iterable[Character]) -> Iterable
54
54
  def aggressive_update() -> None:
55
55
  """Wake aggressive NPCs and initiate combat when players enter their rooms."""
56
56
 
57
- for watcher in list(character_registry):
57
+ # mirroring ROM src/update.c:1087 — aggr_update walks ``char_list`` for the
58
+ # PC watcher ``wch``; src/db.c:2256-2257 / src/nanny.c:757-758 head-insert,
59
+ # so ROM visits the NEWEST character first. ``character_registry`` is
60
+ # append-order, so iterate it reversed — load-bearing for the shared RNG
61
+ # draw order (number_bits(1) aggression coin, number_range victim
62
+ # reservoir), like violence_tick/char_update/mobile_update. GL-043.
63
+ for watcher in reversed(list(character_registry)):
64
+ # mirroring ROM's saved ``wch_next`` pointer — a char extracted
65
+ # mid-tick (multi_hit kill) is never revisited in ROM's walk. GL-043.
66
+ if watcher not in character_registry:
67
+ continue
58
68
  if getattr(watcher, "is_npc", False):
59
69
  continue
60
70
  if watcher.is_immortal():
@@ -98,9 +108,12 @@ def aggressive_update() -> None:
98
108
  if victim is None:
99
109
  continue
100
110
 
111
+ # mirroring ROM src/update.c:1136 — aggr_update ends each aggression
112
+ # with a bare multi_hit(ch, victim, TYPE_UNDEFINED). It does NOT call
113
+ # check_assist: auto-assist is exclusively violence_update's job
114
+ # (src/fight.c:90, run on the next PULSE_VIOLENCE — Python's
115
+ # game_loop.violence_tick). Calling check_assist here started assists
116
+ # a full tick early and drew extra coins from the shared MM RNG
117
+ # stream (the comment that used to cite "fight.c:90" mislabeled the
118
+ # violence_update site as if it belonged to aggr_update).
101
119
  multi_hit(mob, victim)
102
-
103
- # ROM src/fight.c:90 - Check for assist after combat starts
104
- from mud.combat.assist import check_assist
105
-
106
- check_assist(mob, victim)
mud/characters/follow.py CHANGED
@@ -3,24 +3,13 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  from mud.models.constants import AffectFlag
6
+ from mud.utils.act import act_format
6
7
  from mud.utils.messaging import push_message
7
8
 
8
9
  if TYPE_CHECKING: # pragma: no cover - import for type checkers only
9
10
  from mud.models.character import Character
10
11
 
11
12
 
12
- def _display_name(character: Character | None) -> str:
13
- if character is None:
14
- return "Someone"
15
- name = getattr(character, "name", None)
16
- if isinstance(name, str) and name:
17
- return name
18
- short_descr = getattr(character, "short_descr", None)
19
- if isinstance(short_descr, str) and short_descr:
20
- return short_descr
21
- return "Someone"
22
-
23
-
24
13
  def add_follower(follower: Character, master: Character) -> None:
25
14
  """Attach ``follower`` to ``master`` mirroring ROM ``add_follower``."""
26
15
  # mirroring ROM src/act_comm.c:1591-1607
@@ -38,10 +27,13 @@ def add_follower(follower: Character, master: Character) -> None:
38
27
  if can_see_character(master, follower):
39
28
  # mirroring ROM src/act_comm.c:1602-1603 — act(..., TO_VICT)
40
29
  # writes immediately to the descriptor; mailbox is fallback only.
41
- push_message(master, f"{_display_name(follower)} now follows you.")
30
+ # FOLLOW-003: ROM act("$n now follows you.", ch, NULL, master, TO_VICT)
31
+ # $n = PERS(follower, master) = NPC short_descr (cap), not the keyword name.
32
+ push_message(master, act_format("$n now follows you.", recipient=master, actor=follower))
42
33
 
43
- # ROM line 1605: TO_CHAR is unconditional.
44
- push_message(follower, f"You now follow {_display_name(master)}.")
34
+ # ROM line 1605: TO_CHAR is unconditional. FOLLOW-004: $N = PERS(master, ch)
35
+ # is gated on can_see(ch, master) an unseen master renders "someone".
36
+ push_message(follower, act_format("You now follow $N.", recipient=follower, arg2=master))
45
37
 
46
38
 
47
39
  def stop_follower(follower: Character) -> None:
@@ -65,8 +57,12 @@ def stop_follower(follower: Character) -> None:
65
57
  # immediately to the descriptor; push_message routes a connected PC to the
66
58
  # async send and falls back to the mailbox only for disconnected chars
67
59
  # (matching add_follower above — ACT_COMM-003 / INV-001 wrong-channel).
68
- push_message(master, f"{_display_name(follower)} stops following you.")
69
- push_message(follower, f"You stop following {_display_name(master)}.")
60
+ # FOLLOW-003: ROM act("$n stops following you.", ch, NULL, ch->master,
61
+ # TO_VICT) — $n = PERS(follower, master) = NPC short_descr (cap).
62
+ push_message(master, act_format("$n stops following you.", recipient=master, actor=follower))
63
+ # FOLLOW-004: $N = PERS(master, ch) — gated on can_see(ch, master); an
64
+ # unseen master (e.g. blind follower) renders "someone".
65
+ push_message(follower, act_format("You stop following $N.", recipient=follower, arg2=master))
70
66
 
71
67
  if getattr(master, "pet", None) is follower:
72
68
  master.pet = None
mud/combat/assist.py CHANGED
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING
10
10
 
11
11
  from mud.characters import is_same_group
12
12
  from mud.combat.engine import is_evil, is_good, is_neutral, multi_hit
13
- from mud.combat.safety import is_safe
14
13
  from mud.models.constants import AffectFlag, OffFlag, PlayerFlag
15
14
  from mud.utils import rng_mm
16
15
  from mud.world.vision import can_see_character
@@ -81,8 +80,23 @@ def check_assist(ch: Character, victim: Character) -> None:
81
80
  is_rch_charmed = _is_affected(rch, AffectFlag.CHARM)
82
81
 
83
82
  if (is_rch_autoassist or is_rch_charmed) and is_same_group(ch, rch):
84
- if not is_safe(rch, victim):
83
+ # INV-050: route through the faithful ROM is_safe() mirror
84
+ # (_kill_safety_message, src/fight.c:1018-1124) rather than the
85
+ # silent bool combat.safety.is_safe. ROM check_assist gates on
86
+ # `!is_safe(rch, victim)` (src/fight.c:131); is_safe writes its
87
+ # OWN rejection line to rch via send_to_char BEFORE returning
88
+ # TRUE (e.g. "Join a clan if you want to kill players." for a
89
+ # non-clan PC autoassister) — so a blocked assister sees the
90
+ # line. A None return == not safe → rch assists. The silent bool
91
+ # lacked the PC clan ladder, so non-clan PCs wrongly auto-assisted
92
+ # in PvP. (Function-local import avoids an engine→command cycle.)
93
+ from mud.commands.combat import _kill_safety_message
94
+
95
+ safety_message = _kill_safety_message(rch, victim)
96
+ if safety_message is None:
85
97
  multi_hit(rch, victim, None)
98
+ else:
99
+ _send_to_char(rch, safety_message + "\n")
86
100
 
87
101
  continue
88
102
 
@@ -95,19 +109,25 @@ def check_assist(ch: Character, victim: Character) -> None:
95
109
  rch_off_flags = getattr(rch, "off_flags", 0)
96
110
  should_assist = False
97
111
 
112
+ # mirroring ROM src/fight.c:141-150 — all five assist conditions are a
113
+ # flat || OR chain; C evaluates each until one is true. Python's
114
+ # `elif` would enter the ASSIST_RACE branch (flag set) and skip
115
+ # ASSIST_ALIGN/ASSIST_VNUM even when the inner race predicate fails.
116
+ # Independent `if` checks restore the ROM || semantics.
117
+
98
118
  # ASSIST_ALL: Assist any mob
99
119
  if rch_off_flags & OffFlag.ASSIST_ALL:
100
120
  should_assist = True
101
121
 
102
- # ASSIST_RACE: Assist same race
103
- elif rch_off_flags & OffFlag.ASSIST_RACE:
122
+ # ASSIST_RACE: Assist same race (ROM src/fight.c:143-144)
123
+ if rch_off_flags & OffFlag.ASSIST_RACE:
104
124
  ch_race = getattr(ch, "race", None)
105
125
  rch_race = getattr(rch, "race", None)
106
126
  if ch_race and rch_race and ch_race == rch_race:
107
127
  should_assist = True
108
128
 
109
- # ASSIST_ALIGN: Assist same alignment
110
- elif rch_off_flags & OffFlag.ASSIST_ALIGN:
129
+ # ASSIST_ALIGN: Assist same alignment (ROM src/fight.c:145-148)
130
+ if rch_off_flags & OffFlag.ASSIST_ALIGN:
111
131
  if (
112
132
  (is_good(rch) and is_good(ch))
113
133
  or (is_evil(rch) and is_evil(ch))
@@ -115,8 +135,8 @@ def check_assist(ch: Character, victim: Character) -> None:
115
135
  ):
116
136
  should_assist = True
117
137
 
118
- # ASSIST_VNUM: Assist same vnum (same mob prototype)
119
- elif rch_off_flags & OffFlag.ASSIST_VNUM:
138
+ # ASSIST_VNUM: Assist same vnum/mob prototype (ROM src/fight.c:149-150)
139
+ if rch_off_flags & OffFlag.ASSIST_VNUM:
120
140
  ch_vnum = getattr(ch, "vnum", None)
121
141
  rch_vnum = getattr(rch, "vnum", None)
122
142
  if ch_vnum is not None and rch_vnum is not None and ch_vnum == rch_vnum:
@@ -137,12 +157,15 @@ def check_assist(ch: Character, victim: Character) -> None:
137
157
  target = None
138
158
  number = 0
139
159
 
140
- # Use reservoir sampling to pick random group member
160
+ # ROM src/fight.c:159-170 number++ is INSIDE the selection block;
161
+ # it only increments when a new target is chosen, not on every
162
+ # visible group member. This matches ROM's biased selection distribution
163
+ # and, critically, keeps the shared MM RNG stream in sync.
141
164
  for vch in list(people_in_room):
142
165
  if can_see_character(rch, vch) and is_same_group(vch, victim):
143
166
  if rng_mm.number_range(0, number) == 0:
144
167
  target = vch
145
- number += 1
168
+ number += 1 # mirroring ROM src/fight.c:165
146
169
 
147
170
  # ROM lines 172-176: Attack the selected target
148
171
  if target is not None: