rom24-quickmud-python 2.9.20__py3-none-any.whl → 2.13.29__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 (134) hide show
  1. mud/account/__init__.py +10 -10
  2. mud/account/account_manager.py +9 -4
  3. mud/account/account_service.py +12 -4
  4. mud/admin_logging/admin.py +2 -2
  5. mud/advancement.py +13 -7
  6. mud/affects/engine.py +10 -5
  7. mud/affects/saves.py +0 -1
  8. mud/ai/__init__.py +16 -12
  9. mud/ai/aggressive.py +6 -24
  10. mud/characters/conditions.py +2 -10
  11. mud/characters/follow.py +25 -17
  12. mud/combat/assist.py +6 -29
  13. mud/combat/death.py +52 -23
  14. mud/combat/engine.py +379 -129
  15. mud/combat/messages.py +34 -15
  16. mud/combat/safety.py +16 -35
  17. mud/commands/admin_commands.py +15 -42
  18. mud/commands/advancement.py +64 -58
  19. mud/commands/affects.py +0 -1
  20. mud/commands/build.py +15 -9
  21. mud/commands/channels.py +8 -8
  22. mud/commands/combat.py +246 -76
  23. mud/commands/communication.py +99 -51
  24. mud/commands/compare.py +5 -6
  25. mud/commands/consider.py +12 -11
  26. mud/commands/consumption.py +27 -32
  27. mud/commands/dispatcher.py +251 -39
  28. mud/commands/doors.py +87 -7
  29. mud/commands/equipment.py +109 -42
  30. mud/commands/feedback.py +2 -3
  31. mud/commands/give.py +35 -31
  32. mud/commands/group_commands.py +100 -76
  33. mud/commands/healer.py +15 -8
  34. mud/commands/help.py +2 -2
  35. mud/commands/imc.py +6 -6
  36. mud/commands/imm_admin.py +2 -5
  37. mud/commands/imm_commands.py +142 -29
  38. mud/commands/imm_display.py +35 -11
  39. mud/commands/imm_emote.py +2 -5
  40. mud/commands/imm_load.py +187 -114
  41. mud/commands/imm_olc.py +30 -29
  42. mud/commands/imm_punish.py +2 -5
  43. mud/commands/imm_search.py +58 -17
  44. mud/commands/imm_server.py +12 -10
  45. mud/commands/imm_set.py +53 -36
  46. mud/commands/info.py +10 -11
  47. mud/commands/info_extended.py +3 -3
  48. mud/commands/inspection.py +11 -6
  49. mud/commands/inventory.py +35 -29
  50. mud/commands/liquids.py +43 -41
  51. mud/commands/magic_items.py +46 -62
  52. mud/commands/misc_info.py +22 -15
  53. mud/commands/misc_player.py +83 -81
  54. mud/commands/mobprog_tools.py +2 -2
  55. mud/commands/movement.py +1 -1
  56. mud/commands/murder.py +22 -21
  57. mud/commands/notes.py +4 -11
  58. mud/commands/obj_manipulation.py +82 -61
  59. mud/commands/player_config.py +54 -47
  60. mud/commands/player_info.py +0 -2
  61. mud/commands/position.py +10 -16
  62. mud/commands/remaining_rom.py +8 -21
  63. mud/commands/session.py +29 -15
  64. mud/commands/shop.py +197 -174
  65. mud/commands/socials.py +51 -28
  66. mud/commands/thief_skills.py +61 -66
  67. mud/commands/typo_guards.py +1 -0
  68. mud/config.py +0 -3
  69. mud/db/models.py +4 -4
  70. mud/db/serializers.py +127 -25
  71. mud/diagnostics/__init__.py +0 -0
  72. mud/diagnostics/invariants.py +75 -0
  73. mud/game_loop.py +330 -122
  74. mud/groups/xp.py +26 -7
  75. mud/handler.py +167 -285
  76. mud/imc/__init__.py +54 -76
  77. mud/imc/commands.py +9 -8
  78. mud/loaders/__init__.py +3 -3
  79. mud/loaders/json_loader.py +30 -8
  80. mud/loaders/mob_loader.py +15 -6
  81. mud/loaders/reset_loader.py +3 -9
  82. mud/loaders/room_loader.py +34 -3
  83. mud/loaders/specials_loader.py +2 -2
  84. mud/magic/effects.py +13 -14
  85. mud/math/stat_apps.py +103 -113
  86. mud/mob_cmds.py +26 -26
  87. mud/mobprog.py +182 -18
  88. mud/models/board.py +1 -3
  89. mud/models/character.py +250 -112
  90. mud/models/classes.py +2 -3
  91. mud/models/constants.py +125 -57
  92. mud/models/help.py +2 -3
  93. mud/models/object.py +64 -3
  94. mud/models/races.py +10 -24
  95. mud/models/room.py +43 -16
  96. mud/music/__init__.py +19 -1
  97. mud/net/ansi.py +9 -9
  98. mud/net/connection.py +76 -2
  99. mud/net/protocol.py +20 -7
  100. mud/net/session.py +1 -6
  101. mud/network/websocket_server.py +1 -2
  102. mud/notes.py +2 -2
  103. mud/registry.py +6 -7
  104. mud/scripts/convert_are_to_json.py +1 -4
  105. mud/scripts/convert_skills_to_json.py +20 -2
  106. mud/skills/groups.py +8 -10
  107. mud/skills/handlers.py +724 -408
  108. mud/skills/metadata.py +0 -1
  109. mud/skills/registry.py +6 -1
  110. mud/skills/say_spell.py +4 -3
  111. mud/spawning/reset_handler.py +16 -9
  112. mud/spawning/templates.py +255 -71
  113. mud/spec_funs.py +48 -40
  114. mud/utils/act.py +281 -13
  115. mud/utils/messaging.py +74 -0
  116. mud/utils/string_editor.py +2 -12
  117. mud/utils/timing.py +23 -0
  118. mud/wiznet.py +8 -1
  119. mud/world/char_find.py +59 -27
  120. mud/world/look.py +54 -15
  121. mud/world/movement.py +72 -37
  122. mud/world/obj_find.py +5 -5
  123. mud/world/time_persistence.py +2 -2
  124. mud/world/vision.py +48 -35
  125. mud/world/world_state.py +1 -1
  126. {rom24_quickmud_python-2.9.20.dist-info → rom24_quickmud_python-2.13.29.dist-info}/METADATA +62 -24
  127. rom24_quickmud_python-2.13.29.dist-info/RECORD +226 -0
  128. mud/loaders/json_area_loader.py +0 -244
  129. mud/rom_api.py +0 -690
  130. rom24_quickmud_python-2.9.20.dist-info/RECORD +0 -224
  131. {rom24_quickmud_python-2.9.20.dist-info → rom24_quickmud_python-2.13.29.dist-info}/WHEEL +0 -0
  132. {rom24_quickmud_python-2.9.20.dist-info → rom24_quickmud_python-2.13.29.dist-info}/entry_points.txt +0 -0
  133. {rom24_quickmud_python-2.9.20.dist-info → rom24_quickmud_python-2.13.29.dist-info}/licenses/LICENSE +0 -0
  134. {rom24_quickmud_python-2.9.20.dist-info → rom24_quickmud_python-2.13.29.dist-info}/top_level.txt +0 -0
mud/account/__init__.py CHANGED
@@ -7,35 +7,35 @@ their passwords directly, mirroring ROM src/merc.h PCData.pwd.
7
7
  from .account_manager import load_character, save_character
8
8
  from .account_service import (
9
9
  CreationSelection,
10
+ LoginFailureReason,
11
+ LoginResult,
10
12
  account_exists,
11
13
  character_exists,
14
+ clear_active_accounts,
12
15
  create_account,
13
16
  create_character,
14
17
  create_character_record,
15
- clear_active_accounts,
18
+ finalize_creation_stats,
16
19
  get_creation_classes,
17
20
  get_creation_races,
18
21
  get_hometown_choices,
19
- get_weapon_choices,
20
22
  get_race_archetype,
23
+ get_weapon_choices,
21
24
  is_account_active,
22
25
  is_valid_account_name,
23
26
  is_valid_character_name,
27
+ list_characters,
28
+ login,
24
29
  login_character,
30
+ login_with_host,
25
31
  lookup_creation_class,
26
32
  lookup_creation_race,
27
33
  lookup_hometown,
28
34
  lookup_weapon_choice,
29
- LoginFailureReason,
30
- LoginResult,
31
- list_characters,
32
- login,
33
- login_with_host,
34
- roll_creation_stats,
35
- finalize_creation_stats,
36
- sanitize_account_name,
37
35
  release_account,
38
36
  release_character,
37
+ roll_creation_stats,
38
+ sanitize_account_name,
39
39
  )
40
40
 
41
41
  __all__ = [
@@ -28,16 +28,16 @@ from mud.db.session import SessionLocal
28
28
 
29
29
  if TYPE_CHECKING:
30
30
  from sqlalchemy.orm import Session
31
- from mud.models.character import Character, character_registry, from_orm
32
- from mud.models.constants import ROOM_VNUM_LIMBO, ROOM_VNUM_TEMPLE
33
31
  from mud.db.serializers import (
34
32
  _normalize_int_list,
35
33
  _serialize_colour_table,
34
+ _serialize_groups,
36
35
  _serialize_object,
37
36
  _serialize_pet,
38
37
  _serialize_skill_map,
39
- _serialize_groups,
40
38
  )
39
+ from mud.models.character import Character, character_registry, from_orm
40
+ from mud.models.constants import ROOM_VNUM_LIMBO, ROOM_VNUM_TEMPLE
41
41
 
42
42
 
43
43
  def load_character(char_name: str, _ignored: str | None = None) -> Character | None:
@@ -265,10 +265,12 @@ def save_character_to_db(session: Session, character: Character) -> None:
265
265
 
266
266
  # perm_stats JSON (already stored as string, keep existing column)
267
267
  from mud.models.character import _encode_perm_stats
268
+
268
269
  db_char.perm_stats = _encode_perm_stats(getattr(character, "perm_stat", []))
269
270
 
270
271
  # creation_groups / creation_skills (keep in sync)
271
272
  from mud.models.character import _encode_creation_groups, _encode_creation_skills
273
+
272
274
  db_char.creation_groups = _encode_creation_groups(getattr(character, "creation_groups", ()))
273
275
  db_char.creation_skills = _encode_creation_skills(getattr(character, "creation_skills", ()))
274
276
  db_char.creation_points = int(getattr(character, "creation_points", 0) or 0)
@@ -312,6 +314,7 @@ def save_character_to_db(session: Session, character: Character) -> None:
312
314
  def _dataclass_to_dict(obj: object) -> dict:
313
315
  """Recursively convert a dataclass instance to a plain dict for JSON storage."""
314
316
  import dataclasses
317
+
315
318
  if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
316
319
  result = {}
317
320
  for f in dataclasses.fields(obj): # type: ignore[arg-type]
@@ -320,7 +323,9 @@ def _dataclass_to_dict(obj: object) -> dict:
320
323
  result[f.name] = _dataclass_to_dict(val)
321
324
  elif isinstance(val, list):
322
325
  result[f.name] = [
323
- _dataclass_to_dict(item) if (dataclasses.is_dataclass(item) and not isinstance(item, type)) else item
326
+ _dataclass_to_dict(item)
327
+ if (dataclasses.is_dataclass(item) and not isinstance(item, type))
328
+ else item
324
329
  for item in val
325
330
  ]
326
331
  elif isinstance(val, dict):
@@ -101,6 +101,7 @@ _WEAPON_CHOICES: Final[dict[str, tuple[str, ...]]] = {
101
101
  "warrior": ("sword", "mace"),
102
102
  }
103
103
 
104
+
104
105
  @lru_cache(maxsize=1)
105
106
  def _load_skill_data() -> dict[str, dict[str, object]]:
106
107
  """Return ROM skill metadata (type/ratings) keyed by lower-case name."""
@@ -152,7 +153,7 @@ def _skill_ratings(name: str) -> tuple[int, int, int, int] | None:
152
153
  metadata = ROM_SKILL_METADATA.get(normalized)
153
154
  if metadata is not None:
154
155
  ratings_meta = metadata.get("ratings")
155
- if isinstance(ratings_meta, (list, tuple)) and len(ratings_meta) >= 4:
156
+ if isinstance(ratings_meta, list | tuple) and len(ratings_meta) >= 4:
156
157
  try:
157
158
  return tuple(int(value) for value in ratings_meta[:4])
158
159
  except (TypeError, ValueError):
@@ -433,8 +434,13 @@ class CreationSelection:
433
434
  return base + 40
434
435
 
435
436
  def train_value(self) -> int:
436
- if self.creation_points < 40:
437
- return max(0, (40 - self.creation_points + 1) // 2)
437
+ # mirroring ROM src/nanny.c:776 CON_READ_MOTD unconditionally sets
438
+ # ch->train = 3 for every level-0 character, overwriting the
439
+ # `ch->train = (40 - points + 1) / 2` formula from src/nanny.c:684.
440
+ # The formula never survives to CON_PLAYING (it is dead code in ROM),
441
+ # so a freshly created PC always starts with exactly 3 training
442
+ # sessions regardless of how many creation points were spent. (practice
443
+ # is likewise hardcoded to 5 at src/nanny.c:777.)
438
444
  return 3
439
445
 
440
446
  def group_names(self) -> tuple[str, ...]:
@@ -957,7 +963,9 @@ def list_characters(
957
963
  if not name:
958
964
  return []
959
965
  if require_act_flags is not None:
960
- required_bits = int(require_act_flags) if not isinstance(require_act_flags, PlayerFlag) else int(require_act_flags)
966
+ required_bits = (
967
+ int(require_act_flags) if not isinstance(require_act_flags, PlayerFlag) else int(require_act_flags)
968
+ )
961
969
  act_flags = int(getattr(account, "act", 0) or 0)
962
970
  if act_flags & required_bits != required_bits:
963
971
  return []
@@ -80,7 +80,7 @@ def _duplicate_wiznet_chars(text: str) -> str:
80
80
  return "".join(duplicated)
81
81
 
82
82
 
83
- def _get_effective_trust(character: "Character") -> int:
83
+ def _get_effective_trust(character: Character) -> int:
84
84
  """Mirror ROM's ``get_trust`` helper used for wiznet broadcasts."""
85
85
 
86
86
  trust = getattr(character, "trust", 0)
@@ -91,7 +91,7 @@ def log_admin_command(
91
91
  actor: str,
92
92
  command_line: str,
93
93
  *,
94
- character: "Character" | None = None,
94
+ character: Character | None = None,
95
95
  ) -> None:
96
96
  """Append a single admin-command entry to log/admin.log.
97
97
 
mud/advancement.py CHANGED
@@ -11,6 +11,7 @@ from mud.models.constants import LEVEL_HERO
11
11
  from mud.models.races import PcRaceType, list_playable_races
12
12
  from mud.models.titles import default_title_text
13
13
  from mud.utils import rng_mm
14
+ from mud.utils.messaging import send_to_char_buffered
14
15
  from mud.wiznet import WiznetFlag, wiznet
15
16
 
16
17
  BASE_XP_PER_LEVEL = 1000
@@ -53,9 +54,7 @@ def exp_per_level(char: Character) -> int:
53
54
  return _creation_exp_floor(char, BASE_XP_PER_LEVEL)
54
55
 
55
56
 
56
- def exp_per_level_for_creation(
57
- race: PcRaceType, class_type: ClassType, creation_points: int
58
- ) -> int:
57
+ def exp_per_level_for_creation(race: PcRaceType, class_type: ClassType, creation_points: int) -> int:
59
58
  """ROM-style experience curve based on creation points."""
60
59
 
61
60
  points = max(0, int(creation_points))
@@ -144,7 +143,7 @@ def advance_level(char: Character) -> None:
144
143
 
145
144
  set_title(char, default_title_text(char.ch_class, char.level, getattr(char, "sex", 0)))
146
145
 
147
- if hasattr(char, "send_to_char") and not getattr(char, "is_npc", False):
146
+ if not getattr(char, "is_npc", False):
148
147
  hit_suffix = "" if hp == 1 else "s"
149
148
  practice_suffix = "" if practice_gain == 1 else "s"
150
149
  message = (
@@ -152,7 +151,11 @@ def advance_level(char: Character) -> None:
152
151
  f"{hp} hit point{hit_suffix}, {mana} mana, {move} move, and "
153
152
  f"{practice_gain} practice{practice_suffix}.{ROM_NEWLINE}"
154
153
  )
155
- char.send_to_char(message)
154
+ # mirroring ROM src/update.c:113 advance_level — `send_to_char(buf, ch)`
155
+ # straight to the descriptor. advance_level fires from gain_exp during a
156
+ # combat-tick kill; route through the async-aware helper so a leveling PC
157
+ # sees it immediately, not at the next command (mailbox-strand bug).
158
+ send_to_char_buffered(char, message)
156
159
 
157
160
 
158
161
  def _creation_exp_floor(char: Character, fallback: int) -> int:
@@ -217,8 +220,10 @@ def gain_exp(char: Character, amount: int) -> None:
217
220
 
218
221
  # Level up while total exp meets threshold for next level.
219
222
  while char.level < LEVEL_HERO and char.exp >= exp_per_level(char) * (char.level + 1):
220
- if hasattr(char, "send_to_char"):
221
- char.send_to_char("{GYou raise a level!! {x")
223
+ # mirroring ROM src/update.c:131 gain_exp — `send_to_char(...)` straight
224
+ # to the descriptor during the combat tick; async-aware delivery so the
225
+ # ding shows at kill time, not at the player's next command.
226
+ send_to_char_buffered(char, "{GYou raise a level!! {x")
222
227
  char.level += 1
223
228
  log_game_event(f"{getattr(char, 'name', 'Someone')} gained level {char.level}")
224
229
  wiznet(
@@ -232,4 +237,5 @@ def gain_exp(char: Character, amount: int) -> None:
232
237
  advance_level(char)
233
238
  # Lazy import to avoid circular dependency
234
239
  from mud.account.account_manager import save_character
240
+
235
241
  save_character(char)
mud/affects/engine.py CHANGED
@@ -56,9 +56,16 @@ def tick_spell_effects(character: Character) -> list[str]:
56
56
  duration = int(getattr(affect, "duration", 0) or 0)
57
57
  if duration > 0:
58
58
  affect.duration = duration - 1
59
+ # ROM src/update.c:768 — `if (number_range(0,4) == 0 && paf->level > 0)`.
60
+ # C `&&` is left-to-right short-circuit and number_range advances MM
61
+ # state as a side effect, so the roll is consumed UNCONDITIONALLY for
62
+ # every duration>0 affect; `level > 0` is only tested afterwards. The
63
+ # operands must NOT be swapped (`level > 0 and number_range(...)` skips
64
+ # the roll at level 0, desyncing the global RNG stream — GL-026).
65
+ fades = rng_mm.number_range(0, 4) == 0
59
66
  level = int(getattr(affect, "level", 0) or 0)
60
- if level > 0 and rng_mm.number_range(0, 4) == 0:
61
- affect.level = level - 1 # mirroring ROM src/update.c:765-768
67
+ if fades and level > 0:
68
+ affect.level = level - 1
62
69
  spell_name = getattr(affect, "type", None)
63
70
  if isinstance(spell_name, str) and spell_name in effects:
64
71
  touched_names.add(spell_name)
@@ -114,9 +121,7 @@ def tick_spell_effects(character: Character) -> list[str]:
114
121
 
115
122
  for spell_name in touched_names:
116
123
  remaining = [
117
- affect
118
- for affect in getattr(character, "affected", [])
119
- if getattr(affect, "type", None) == spell_name
124
+ affect for affect in getattr(character, "affected", []) if getattr(affect, "type", None) == spell_name
120
125
  ]
121
126
  if remaining:
122
127
  primary = remaining[0]
mud/affects/saves.py CHANGED
@@ -5,7 +5,6 @@ from mud.models.character import Character
5
5
  from mud.models.constants import AffectFlag, DamageType, DefenseBit
6
6
  from mud.utils import rng_mm
7
7
 
8
-
9
8
  ROM_NEWLINE = "\n\r"
10
9
 
11
10
  # Minimal fMana mapping from ROM const.c order: mage, cleric → True; thief, warrior → False
mud/ai/__init__.py CHANGED
@@ -170,18 +170,22 @@ def _take_object(mob: Character, obj: Object) -> None:
170
170
  obj.location = None
171
171
  if hasattr(obj, "in_obj"):
172
172
  obj.in_obj = None
173
- if hasattr(obj, "carried_by"):
174
- obj.carried_by = mob
175
-
176
- inventory = getattr(mob, "inventory", None)
177
- if isinstance(inventory, list) and obj not in inventory:
178
- inventory.append(obj)
179
-
180
- weight = int(getattr(obj, "weight", 0) or 0)
181
- if hasattr(mob, "carry_number"):
182
- mob.carry_number = int(getattr(mob, "carry_number", 0) or 0) + 1
183
- if hasattr(mob, "carry_weight"):
184
- mob.carry_weight = int(getattr(mob, "carry_weight", 0) or 0) + weight
173
+ # obj.carried_by is set by add_object / add_to_inventory below
174
+
175
+ # INV-039 / class-13: route through head-insert chokepoint.
176
+ # Character.add_object / MobInstance.add_to_inventory handles carry_number
177
+ # and carry_weight internally no manual counter update needed.
178
+ add_obj = getattr(mob, "add_object", None)
179
+ if callable(add_obj):
180
+ add_obj(obj)
181
+ else:
182
+ add_inv = getattr(mob, "add_to_inventory", None)
183
+ if callable(add_inv):
184
+ add_inv(obj)
185
+ else:
186
+ inventory = getattr(mob, "inventory", None)
187
+ if isinstance(inventory, list) and obj not in inventory:
188
+ inventory.insert(0, obj)
185
189
 
186
190
  room = getattr(mob, "room", None)
187
191
  if room is not None and hasattr(room, "broadcast"):
mud/ai/aggressive.py CHANGED
@@ -6,7 +6,7 @@ from collections.abc import Iterable
6
6
 
7
7
  from mud.combat import multi_hit
8
8
  from mud.models.character import Character, character_registry
9
- from mud.models.constants import ActFlag, AffectFlag, LEVEL_IMMORTAL, Position, RoomFlag
9
+ from mud.models.constants import LEVEL_IMMORTAL, ActFlag, AffectFlag, RoomFlag
10
10
  from mud.utils import rng_mm
11
11
 
12
12
 
@@ -17,24 +17,6 @@ def _has_flag(value: int, flag: ActFlag) -> bool:
17
17
  return False
18
18
 
19
19
 
20
- def _has_affect(ch: Character, flag: AffectFlag) -> bool:
21
- checker = getattr(ch, "has_affect", None)
22
- if callable(checker):
23
- return bool(checker(flag))
24
- affected = getattr(ch, "affected_by", 0)
25
- try:
26
- return bool(int(affected) & int(flag))
27
- except Exception:
28
- return False
29
-
30
-
31
- def _is_awake(ch: Character) -> bool:
32
- checker = getattr(ch, "is_awake", None)
33
- if callable(checker):
34
- return bool(checker())
35
- return int(getattr(ch, "position", 0)) > int(Position.SLEEPING)
36
-
37
-
38
20
  def _can_see(attacker: Character, target: Character | None) -> bool:
39
21
  if attacker is None or target is None:
40
22
  return False
@@ -62,7 +44,7 @@ def _eligible_victims(ch: Character, occupants: Iterable[Character]) -> Iterable
62
44
  continue
63
45
  if int(getattr(ch, "level", 0)) < int(getattr(candidate, "level", 0)) - 5:
64
46
  continue
65
- if _has_flag(getattr(ch, "act", 0), ActFlag.WIMPY) and _is_awake(candidate):
47
+ if _has_flag(getattr(ch, "act", 0), ActFlag.WIMPY) and candidate.is_awake():
66
48
  continue
67
49
  if not _can_see(ch, candidate):
68
50
  continue
@@ -91,15 +73,15 @@ def aggressive_update() -> None:
91
73
  continue
92
74
  if int(getattr(room, "room_flags", 0)) & int(RoomFlag.ROOM_SAFE):
93
75
  continue
94
- if _has_affect(mob, AffectFlag.CALM):
76
+ if mob.has_affect(AffectFlag.CALM):
95
77
  continue
96
78
  if getattr(mob, "fighting", None) is not None:
97
79
  continue
98
- if _has_affect(mob, AffectFlag.CHARM):
80
+ if mob.has_affect(AffectFlag.CHARM):
99
81
  continue
100
- if not _is_awake(mob):
82
+ if not mob.is_awake():
101
83
  continue
102
- if _has_flag(getattr(mob, "act", 0), ActFlag.WIMPY) and _is_awake(watcher):
84
+ if _has_flag(getattr(mob, "act", 0), ActFlag.WIMPY) and watcher.is_awake():
103
85
  continue
104
86
  if not _can_see(mob, watcher):
105
87
  continue
@@ -1,20 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from mud.models.character import Character
4
- from mud.models.constants import Condition, LEVEL_IMMORTAL
5
-
4
+ from mud.models.constants import LEVEL_IMMORTAL, Condition
5
+ from mud.utils.messaging import send_to_char_buffered as _send_to_char
6
6
 
7
7
  __all__ = ["gain_condition"]
8
8
 
9
9
 
10
- def _send_to_char(character: Character, message: str) -> None:
11
- """Append a message to the character if a buffer is available."""
12
-
13
- messages = getattr(character, "messages", None)
14
- if isinstance(messages, list):
15
- messages.append(message)
16
-
17
-
18
10
  def gain_condition(character: Character, condition: Condition, delta: int) -> None:
19
11
  """Adjust a player's condition slot, mirroring ROM gain_condition."""
20
12
 
mud/characters/follow.py CHANGED
@@ -3,12 +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.messaging import push_message
6
7
 
7
8
  if TYPE_CHECKING: # pragma: no cover - import for type checkers only
8
9
  from mud.models.character import Character
9
10
 
10
11
 
11
- def _display_name(character: "Character" | None) -> str:
12
+ def _display_name(character: Character | None) -> str:
12
13
  if character is None:
13
14
  return "Someone"
14
15
  name = getattr(character, "name", None)
@@ -20,8 +21,10 @@ def _display_name(character: "Character" | None) -> str:
20
21
  return "Someone"
21
22
 
22
23
 
23
- def add_follower(follower: "Character", master: "Character") -> None:
24
+ def add_follower(follower: Character, master: Character) -> None:
24
25
  """Attach ``follower`` to ``master`` mirroring ROM ``add_follower``."""
26
+ # mirroring ROM src/act_comm.c:1591-1607
27
+ from mud.world.vision import can_see_character
25
28
 
26
29
  if getattr(follower, "master", None) is master:
27
30
  return
@@ -31,17 +34,20 @@ def add_follower(follower: "Character", master: "Character") -> None:
31
34
  follower.master = master
32
35
  follower.leader = None
33
36
 
34
- master_messages = getattr(master, "messages", None)
35
- if isinstance(master_messages, list):
36
- master_messages.append(f"{_display_name(follower)} now follows you.")
37
+ # ROM lines 1602-1603: TO_VICT gated on can_see(master, ch).
38
+ if can_see_character(master, follower):
39
+ # mirroring ROM src/act_comm.c:1602-1603 act(..., TO_VICT)
40
+ # writes immediately to the descriptor; mailbox is fallback only.
41
+ push_message(master, f"{_display_name(follower)} now follows you.")
37
42
 
38
- follower_messages = getattr(follower, "messages", None)
39
- if isinstance(follower_messages, list):
40
- follower_messages.append(f"You now follow {_display_name(master)}.")
43
+ # ROM line 1605: TO_CHAR is unconditional.
44
+ push_message(follower, f"You now follow {_display_name(master)}.")
41
45
 
42
46
 
43
- def stop_follower(follower: "Character") -> None:
47
+ def stop_follower(follower: Character) -> None:
44
48
  """Detach ``follower`` from its master and clear charm effects."""
49
+ # mirroring ROM src/act_comm.c:1612-1636
50
+ from mud.world.vision import can_see_character
45
51
 
46
52
  master = getattr(follower, "master", None)
47
53
  if master is None:
@@ -52,13 +58,15 @@ def stop_follower(follower: "Character") -> None:
52
58
  elif follower.has_affect(AffectFlag.CHARM):
53
59
  follower.remove_affect(AffectFlag.CHARM)
54
60
 
55
- master_messages = getattr(master, "messages", None)
56
- if isinstance(master_messages, list):
57
- master_messages.append(f"{_display_name(follower)} stops following you.")
58
-
59
- follower_messages = getattr(follower, "messages", None)
60
- if isinstance(follower_messages, list):
61
- follower_messages.append(f"You stop following {_display_name(master)}.")
61
+ # ROM lines 1625-1629: both messages gated on
62
+ # can_see(ch->master, ch) && ch->in_room != NULL.
63
+ if can_see_character(master, follower) and getattr(follower, "room", None) is not None:
64
+ # ROM src/act_comm.c:1626-1627 — act(..., TO_VICT)/act(..., TO_CHAR) write
65
+ # immediately to the descriptor; push_message routes a connected PC to the
66
+ # async send and falls back to the mailbox only for disconnected chars
67
+ # (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)}.")
62
70
 
63
71
  if getattr(master, "pet", None) is follower:
64
72
  master.pet = None
@@ -67,7 +75,7 @@ def stop_follower(follower: "Character") -> None:
67
75
  follower.leader = None
68
76
 
69
77
 
70
- def die_follower(char: "Character") -> None:
78
+ def die_follower(char: Character) -> None:
71
79
  """Detach a dying character from its group and followers.
72
80
 
73
81
  Mirrors ROM ``src/act_comm.c:1658-1680``:
mud/combat/assist.py CHANGED
@@ -6,7 +6,6 @@ ROM Reference: src/fight.c check_assist (lines 105-181)
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import asyncio
10
9
  from typing import TYPE_CHECKING
11
10
 
12
11
  from mud.characters import is_same_group
@@ -56,7 +55,7 @@ def check_assist(ch: Character, victim: Character) -> None:
56
55
  # ROM uses rch_next pattern to handle this
57
56
  for rch in list(people_in_room): # Create a copy to avoid modification issues
58
57
  # Skip if not awake or already fighting
59
- if not _is_awake(rch):
58
+ if not rch.is_awake():
60
59
  continue
61
60
 
62
61
  if getattr(rch, "fighting", None) is not None:
@@ -151,14 +150,6 @@ def check_assist(ch: Character, victim: Character) -> None:
151
150
  multi_hit(rch, target, None)
152
151
 
153
152
 
154
- def _is_awake(char: Character) -> bool:
155
- """Check if character is awake (ROM IS_AWAKE macro)."""
156
- from mud.models.constants import Position
157
-
158
- position = getattr(char, "position", Position.STANDING)
159
- return position > Position.SLEEPING
160
-
161
-
162
153
  def _is_npc(char: Character) -> bool:
163
154
  """Check if character is NPC (ROM IS_NPC macro)."""
164
155
  return getattr(char, "is_npc", False)
@@ -190,22 +181,8 @@ def _emote(char: Character, message: str) -> None:
190
181
  people = getattr(room, "people", [])
191
182
  for person in people:
192
183
  if person != char:
193
- _send_to_char(person, full_message)
194
-
195
-
196
- def _send_to_char(char: Character, message: str) -> None:
197
- """Send a message to a character, mirroring ROM C write_to_buffer."""
198
- # Direct delivery for connected characters
199
- writer = getattr(char, "connection", None)
200
- if writer is not None:
201
- from mud.net.protocol import send_to_char as _send
202
-
203
- asyncio.create_task(_send(char, message + "\n"))
204
- # Queue fallback for tests
205
- send = getattr(char, "send", None)
206
- if callable(send):
207
- send(message + "\n")
208
- else:
209
- messages = getattr(char, "messages", None)
210
- if isinstance(messages, list):
211
- messages.append(message + "\n")
184
+ _send_to_char(person, full_message + "\n")
185
+
186
+
187
+ # DUPL-001b canonical at mud/utils/messaging.py:send_to_char_buffered.
188
+ from mud.utils.messaging import send_to_char_buffered as _send_to_char # noqa: E402