rom24-quickmud-python 2.0.5__py3-none-any.whl → 2.1.0__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 (87) hide show
  1. mud/account/account_manager.py +46 -3
  2. mud/account/account_service.py +23 -54
  3. mud/advancement.py +2 -1
  4. mud/combat/engine.py +15 -33
  5. mud/combat/safety.py +106 -0
  6. mud/commands/admin_commands.py +1 -1
  7. mud/commands/affects.py +98 -0
  8. mud/commands/auto_settings.py +340 -0
  9. mud/commands/build.py +1388 -12
  10. mud/commands/channels.py +51 -0
  11. mud/commands/character.py +159 -0
  12. mud/commands/combat.py +421 -0
  13. mud/commands/communication.py +87 -8
  14. mud/commands/compare.py +144 -0
  15. mud/commands/consider.py +68 -0
  16. mud/commands/consumption.py +257 -0
  17. mud/commands/dispatcher.py +360 -12
  18. mud/commands/doors.py +508 -0
  19. mud/commands/equipment.py +306 -0
  20. mud/commands/feedback.py +93 -0
  21. mud/commands/give.py +196 -0
  22. mud/commands/group_commands.py +393 -0
  23. mud/commands/help.py +28 -11
  24. mud/commands/imc.py +12 -10
  25. mud/commands/imm_admin.py +280 -0
  26. mud/commands/imm_commands.py +457 -0
  27. mud/commands/imm_display.py +281 -0
  28. mud/commands/imm_emote.py +156 -0
  29. mud/commands/imm_load.py +382 -0
  30. mud/commands/imm_olc.py +254 -0
  31. mud/commands/imm_punish.py +259 -0
  32. mud/commands/imm_search.py +593 -0
  33. mud/commands/imm_server.py +239 -0
  34. mud/commands/imm_set.py +500 -0
  35. mud/commands/info.py +277 -0
  36. mud/commands/info_extended.py +315 -0
  37. mud/commands/inspection.py +28 -3
  38. mud/commands/inventory.py +61 -3
  39. mud/commands/liquids.py +246 -0
  40. mud/commands/magic_items.py +451 -0
  41. mud/commands/misc_info.py +270 -0
  42. mud/commands/misc_player.py +274 -0
  43. mud/commands/mobprog_tools.py +40 -0
  44. mud/commands/murder.py +122 -0
  45. mud/commands/obj_manipulation.py +501 -0
  46. mud/commands/player_config.py +216 -0
  47. mud/commands/player_info.py +181 -0
  48. mud/commands/position.py +85 -0
  49. mud/commands/remaining_rom.py +552 -0
  50. mud/commands/session.py +267 -0
  51. mud/commands/shop.py +19 -29
  52. mud/commands/thief_skills.py +351 -0
  53. mud/commands/typo_guards.py +86 -0
  54. mud/db/migrations.py +24 -2
  55. mud/db/models.py +3 -0
  56. mud/game_loop.py +46 -18
  57. mud/loaders/__init__.py +15 -2
  58. mud/loaders/area_loader.py +27 -54
  59. mud/loaders/base_loader.py +13 -3
  60. mud/loaders/help_loader.py +9 -2
  61. mud/loaders/json_loader.py +3 -0
  62. mud/mobprog.py +131 -0
  63. mud/models/character.py +24 -3
  64. mud/net/ansi.py +2 -0
  65. mud/net/connection.py +64 -250
  66. mud/net/telnet_server.py +17 -3
  67. mud/network/websocket_server.py +22 -0
  68. mud/olc/__init__.py +11 -0
  69. mud/olc/save.py +293 -0
  70. mud/persistence.py +9 -0
  71. mud/rom_api.py +677 -0
  72. mud/scripts/convert_are_to_json.py +2 -1
  73. mud/scripts/convert_help_are_to_json.py +42 -2
  74. mud/scripts/convert_player_to_json.py +28 -0
  75. mud/skills/handlers.py +1299 -266
  76. mud/spawning/reset_handler.py +17 -7
  77. mud/wiznet.py +3 -2
  78. mud/world/char_find.py +134 -0
  79. mud/world/look.py +243 -2
  80. mud/world/movement.py +7 -16
  81. mud/world/obj_find.py +180 -0
  82. {rom24_quickmud_python-2.0.5.dist-info → rom24_quickmud_python-2.1.0.dist-info}/METADATA +18 -8
  83. {rom24_quickmud_python-2.0.5.dist-info → rom24_quickmud_python-2.1.0.dist-info}/RECORD +87 -45
  84. {rom24_quickmud_python-2.0.5.dist-info → rom24_quickmud_python-2.1.0.dist-info}/WHEEL +0 -0
  85. {rom24_quickmud_python-2.0.5.dist-info → rom24_quickmud_python-2.1.0.dist-info}/entry_points.txt +0 -0
  86. {rom24_quickmud_python-2.0.5.dist-info → rom24_quickmud_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
  87. {rom24_quickmud_python-2.0.5.dist-info → rom24_quickmud_python-2.1.0.dist-info}/top_level.txt +0 -0
@@ -44,15 +44,46 @@ def save_character(character: Character) -> None:
44
44
  try:
45
45
  session = SessionLocal()
46
46
  db_char = session.query(DBCharacter).filter_by(name=character.name).first()
47
+ if not db_char:
48
+ # Character doesn't exist in database - create it
49
+ # This handles cases where character was created via JSON or other means
50
+ print(f"[WARN] Character '{character.name}' not found in database, creating new record")
51
+
52
+ # CRITICAL: Try to find and link the player account
53
+ player_id = None
54
+ pcdata = getattr(character, "pcdata", None)
55
+ if pcdata:
56
+ account_name = getattr(pcdata, "account_name", None)
57
+ if account_name:
58
+ player_account = session.query(PlayerAccount).filter_by(username=account_name).first()
59
+ if player_account:
60
+ player_id = player_account.id
61
+ print(f"[INFO] Linked character '{character.name}' to account '{account_name}' (id={player_id})")
62
+ else:
63
+ print(f"[WARN] Could not find player account '{account_name}' for character '{character.name}'")
64
+
65
+ db_char = DBCharacter(name=character.name, player_id=player_id)
66
+ session.add(db_char)
67
+
68
+ # Update all fields
47
69
  if db_char:
70
+ # Ensure player_id is set if we have account information
71
+ if not db_char.player_id:
72
+ pcdata = getattr(character, "pcdata", None)
73
+ if pcdata:
74
+ account_name = getattr(pcdata, "account_name", None)
75
+ if account_name:
76
+ player_account = session.query(PlayerAccount).filter_by(username=account_name).first()
77
+ if player_account:
78
+ db_char.player_id = player_account.id
79
+ print(f"[INFO] Fixed missing player_id for character '{character.name}' -> account '{account_name}'")
80
+
48
81
  db_char.level = character.level
49
82
  db_char.hp = character.hit
50
83
  db_char.race = int(character.race or 0)
51
84
  db_char.ch_class = int(character.ch_class or 0)
52
85
  pcdata = getattr(character, "pcdata", None)
53
- true_sex_value = int(
54
- getattr(pcdata, "true_sex", getattr(character, "sex", 0)) or 0
55
- )
86
+ true_sex_value = int(getattr(pcdata, "true_sex", getattr(character, "sex", 0)) or 0)
56
87
  db_char.true_sex = true_sex_value
57
88
  db_char.sex = int(character.sex or true_sex_value or 0)
58
89
  db_char.alignment = int(character.alignment or 0)
@@ -67,6 +98,18 @@ def save_character(character: Character) -> None:
67
98
  db_char.vuln_flags = int(character.vuln_flags or 0)
68
99
  db_char.practice = int(character.practice or 0)
69
100
  db_char.train = int(character.train or 0)
101
+
102
+ # Save perm stats from pcdata (ROM src/handler.c stores perm_hit/perm_mana/perm_move)
103
+ if pcdata:
104
+ db_char.perm_hit = int(getattr(pcdata, "perm_hit", character.max_hit or 20))
105
+ db_char.perm_mana = int(getattr(pcdata, "perm_mana", character.max_mana or 100))
106
+ db_char.perm_move = int(getattr(pcdata, "perm_move", character.max_move or 100))
107
+ else:
108
+ # Fallback if no pcdata
109
+ db_char.perm_hit = int(character.max_hit or 20)
110
+ db_char.perm_mana = int(character.max_mana or 100)
111
+ db_char.perm_move = int(character.max_move or 100)
112
+
70
113
  db_char.default_weapon_vnum = int(character.default_weapon_vnum or 0)
71
114
  db_char.creation_points = int(getattr(character, "creation_points", 0) or 0)
72
115
  db_char.creation_groups = json.dumps(list(getattr(character, "creation_groups", ())))
@@ -80,9 +80,7 @@ _RESERVED_NAMES = {
80
80
  }
81
81
 
82
82
 
83
- _HOMETOWN_CHOICES: Final[tuple[tuple[str, int], ...]] = (
84
- ("Midgaard", ROOM_VNUM_SCHOOL),
85
- )
83
+ _HOMETOWN_CHOICES: Final[tuple[tuple[str, int], ...]] = (("Midgaard", ROOM_VNUM_SCHOOL),)
86
84
 
87
85
  _DEFAULT_WEAPONS: Final[tuple[str, ...]] = ("dagger",)
88
86
 
@@ -377,9 +375,7 @@ class CreationSelection:
377
375
  cost = self._chosen_skills.pop(normalized, 0)
378
376
  if cost > 0:
379
377
  self.creation_points = max(self._base_points, self.creation_points - cost)
380
- self._skill_order = [
381
- entry for entry in self._skill_order if self._normalize(entry) != normalized
382
- ]
378
+ self._skill_order = [entry for entry in self._skill_order if self._normalize(entry) != normalized]
383
379
  if not sources:
384
380
  self._skill_sources.pop(normalized, None)
385
381
  self._known_skills.discard(normalized)
@@ -396,11 +392,7 @@ class CreationSelection:
396
392
  if not sources:
397
393
  self._group_sources.pop(normalized, None)
398
394
  self._known_groups.discard(normalized)
399
- self._ordered_groups = [
400
- entry
401
- for entry in self._ordered_groups
402
- if self._normalize(entry) != normalized
403
- ]
395
+ self._ordered_groups = [entry for entry in self._ordered_groups if self._normalize(entry) != normalized]
404
396
  for child in self._group_children.get(normalized, set()):
405
397
  self._remove_group_source(child, f"group:{normalized}")
406
398
  for skill in self._group_skill_children.get(normalized, set()):
@@ -418,11 +410,7 @@ class CreationSelection:
418
410
  self._skill_sources.pop(normalized, None)
419
411
  self._known_skills.discard(normalized)
420
412
  self._chosen_skills.pop(normalized, None)
421
- self._skill_order = [
422
- entry
423
- for entry in self._skill_order
424
- if self._normalize(entry) != normalized
425
- ]
413
+ self._skill_order = [entry for entry in self._skill_order if self._normalize(entry) != normalized]
426
414
  else:
427
415
  self._skill_sources[normalized] = sources
428
416
 
@@ -488,11 +476,7 @@ class CreationSelection:
488
476
  if not sources:
489
477
  self._group_sources.pop(normalized, None)
490
478
  self._known_groups.discard(normalized)
491
- self._ordered_groups = [
492
- entry
493
- for entry in self._ordered_groups
494
- if self._normalize(entry) != normalized
495
- ]
479
+ self._ordered_groups = [entry for entry in self._ordered_groups if self._normalize(entry) != normalized]
496
480
  else:
497
481
  self._group_sources[normalized] = sources
498
482
 
@@ -505,9 +489,7 @@ class CreationSelection:
505
489
  return True
506
490
 
507
491
  def experience_per_level(self) -> int:
508
- return exp_per_level_for_creation(
509
- self.race, self.class_type, self.creation_points
510
- )
492
+ return exp_per_level_for_creation(self.race, self.class_type, self.creation_points)
511
493
 
512
494
  def skill_names(self) -> tuple[str, ...]:
513
495
  ordered: list[str] = []
@@ -599,9 +581,7 @@ def is_valid_account_name(username: str) -> bool:
599
581
  return False
600
582
 
601
583
  capitalized = candidate.capitalize()
602
- if capitalized != "Alander" and (
603
- capitalized.startswith("Alan") or capitalized.endswith("Alander")
604
- ):
584
+ if capitalized != "Alander" and (capitalized.startswith("Alan") or capitalized.endswith("Alander")):
605
585
  return False
606
586
 
607
587
  if len(candidate) < 2 or len(candidate) > 12:
@@ -676,9 +656,7 @@ def _clamp_stats_to_race(stats: Iterable[int], race: PcRaceType) -> list[int]:
676
656
  return clamped
677
657
 
678
658
 
679
- def finalize_creation_stats(
680
- race: PcRaceType, class_type: ClassType, stats: Iterable[int]
681
- ) -> list[int]:
659
+ def finalize_creation_stats(race: PcRaceType, class_type: ClassType, stats: Iterable[int]) -> list[int]:
682
660
  """Clamp rolled stats to race bounds and apply the class prime bonus."""
683
661
 
684
662
  clamped = _clamp_stats_to_race(stats, race)
@@ -859,9 +837,7 @@ def login_with_host(
859
837
  _mark_account_active(username)
860
838
  return LoginResult(account, None, reconnect_requested)
861
839
 
862
- failure = (
863
- LoginFailureReason.BAD_CREDENTIALS if exists else LoginFailureReason.UNKNOWN_ACCOUNT
864
- )
840
+ failure = LoginFailureReason.BAD_CREDENTIALS if exists else LoginFailureReason.UNKNOWN_ACCOUNT
865
841
  return LoginResult(None, failure, reconnect_requested)
866
842
 
867
843
 
@@ -936,11 +912,7 @@ def create_character(
936
912
  sex_value = int(Sex.MALE)
937
913
 
938
914
  hometown = hometown_vnum if hometown_vnum is not None else starting_room_vnum
939
- weapon_vnum = (
940
- int(default_weapon_vnum)
941
- if default_weapon_vnum is not None
942
- else selected_class.first_weapon_vnum
943
- )
915
+ weapon_vnum = int(default_weapon_vnum) if default_weapon_vnum is not None else selected_class.first_weapon_vnum
944
916
  default_groups = iter_group_names(
945
917
  (
946
918
  "rom basics",
@@ -948,30 +920,20 @@ def create_character(
948
920
  selected_class.default_group,
949
921
  )
950
922
  )
951
- groups_tuple = (
952
- iter_group_names(creation_groups)
953
- if creation_groups is not None
954
- else default_groups
955
- )
956
- skills_tuple = (
957
- iter_skill_names(creation_skills)
958
- if creation_skills is not None
959
- else ()
960
- )
923
+ groups_tuple = iter_group_names(creation_groups) if creation_groups is not None else default_groups
924
+ skills_tuple = iter_skill_names(creation_skills) if creation_skills is not None else ()
961
925
  default_points = int(selected_race.points) + (
962
926
  _group_cost_for_class(selected_class.default_group, selected_class) or 0
963
927
  )
964
- creation_points_value = (
965
- int(creation_points)
966
- if creation_points is not None
967
- else default_points
968
- )
928
+ creation_points_value = int(creation_points) if creation_points is not None else default_points
969
929
  practice_value = practice if practice is not None else 5
970
930
  train_value = train if train is not None else 3
971
931
 
972
932
  session = SessionLocal()
973
933
  try:
974
- if session.query(Character).filter_by(name=sanitized).first():
934
+ existing = session.query(Character).filter_by(name=sanitized).first()
935
+ if existing:
936
+ print(f"[ERROR] Character creation failed: name '{sanitized}' already exists (id={existing.id})")
975
937
  return False
976
938
 
977
939
  new_char = Character(
@@ -994,6 +956,9 @@ def create_character(
994
956
  vuln_flags=int(archetype.vulnerability_flags) if archetype else 0,
995
957
  practice=int(practice_value),
996
958
  train=int(train_value),
959
+ perm_hit=100,
960
+ perm_mana=100,
961
+ perm_move=100,
997
962
  act=int(PlayerFlag.NOSUMMON),
998
963
  default_weapon_vnum=weapon_vnum,
999
964
  newbie_help_seen=False,
@@ -1004,6 +969,10 @@ def create_character(
1004
969
  )
1005
970
  session.add(new_char)
1006
971
  session.commit()
972
+ print(f"[INFO] Character '{sanitized}' created successfully for account {account.username} (id={new_char.id})")
1007
973
  return True
974
+ except Exception as e:
975
+ print(f"[ERROR] Failed to create character '{sanitized}': {e}")
976
+ return False
1008
977
  finally:
1009
978
  session.close()
mud/advancement.py CHANGED
@@ -7,7 +7,6 @@ from mud.models.character import Character
7
7
  from mud.models.constants import LEVEL_HERO
8
8
  from mud.models.classes import CLASS_TABLE, ClassType
9
9
  from mud.models.races import PcRaceType, list_playable_races
10
- from mud.persistence import save_character
11
10
  from mud.wiznet import WiznetFlag, wiznet
12
11
 
13
12
  BASE_XP_PER_LEVEL = 1000
@@ -199,4 +198,6 @@ def gain_exp(char: Character, amount: int) -> None:
199
198
  None,
200
199
  0,
201
200
  )
201
+ # Lazy import to avoid circular dependency
202
+ from mud.account.account_manager import save_character
202
203
  save_character(char)
mud/combat/engine.py CHANGED
@@ -33,7 +33,7 @@ from mud.models.constants import (
33
33
  LEVEL_IMMORTAL,
34
34
  WearFlag,
35
35
  )
36
- from mud.persistence import save_character
36
+ from mud.account.account_manager import save_character
37
37
  from mud.magic import SpellTarget, cold_effect, fire_effect, shock_effect
38
38
  from mud.models.social import expand_placeholders
39
39
  from mud.utils import rng_mm
@@ -824,19 +824,11 @@ def _auto_sacrifice(attacker: Character, corpse) -> None:
824
824
  if silver_reward == 1:
825
825
  attacker.send_to_char("Mota gives you one silver coin for your sacrifice.")
826
826
  else:
827
- attacker.send_to_char(
828
- f"Mota gives you {silver_reward} silver coins for your sacrifice."
829
- )
827
+ attacker.send_to_char(f"Mota gives you {silver_reward} silver coins for your sacrifice.")
830
828
 
831
- corpse_name = (
832
- getattr(corpse, "short_descr", None)
833
- or getattr(corpse, "name", "")
834
- or "corpse"
835
- )
829
+ corpse_name = getattr(corpse, "short_descr", None) or getattr(corpse, "name", "") or "corpse"
836
830
  room.broadcast(
837
- expand_placeholders(
838
- "$n sacrifices $N to Mota.", attacker, SimpleNamespace(name=corpse_name)
839
- ),
831
+ expand_placeholders("$n sacrifices $N to Mota.", attacker, SimpleNamespace(name=corpse_name)),
840
832
  exclude=attacker,
841
833
  )
842
834
  wiznet(
@@ -906,21 +898,15 @@ def _auto_sacrifice(attacker: Character, corpse) -> None:
906
898
  return
907
899
 
908
900
  attacker.silver = current_silver + share + remainder
909
- attacker.send_to_char(
910
- f"You split {silver_reward} silver coins. Your share is {share + remainder} silver."
911
- )
901
+ attacker.send_to_char(f"You split {silver_reward} silver coins. Your share is {share + remainder} silver.")
912
902
 
913
- split_message = (
914
- f"$n splits {silver_reward} silver coins. Your share is {share} silver."
915
- )
903
+ split_message = f"$n splits {silver_reward} silver coins. Your share is {share} silver."
916
904
  for member in group_members:
917
905
  if member is attacker:
918
906
  continue
919
907
  member.silver = max(0, int(getattr(member, "silver", 0) or 0)) + share
920
908
  if hasattr(member, "messages"):
921
- member.messages.append(
922
- expand_placeholders(split_message, attacker, member)
923
- )
909
+ member.messages.append(expand_placeholders(split_message, attacker, member))
924
910
 
925
911
 
926
912
  def _handle_auto_actions(attacker: Character, corpse) -> None:
@@ -1398,8 +1384,8 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
1398
1384
  if wield is None:
1399
1385
  return messages
1400
1386
 
1401
- # Check that attacker is still fighting victim (ROM condition)
1402
- if getattr(attacker, "fighting", None) != victim:
1387
+ current_target = getattr(attacker, "fighting", None)
1388
+ if current_target is not None and current_target is not victim:
1403
1389
  return messages
1404
1390
 
1405
1391
  # Get weapon flags - support both extra_flags (for ObjIndex) and weapon_flags attribute
@@ -1410,11 +1396,7 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
1410
1396
  weapon_flags = int(getattr(wield, "extra_flags"))
1411
1397
 
1412
1398
  weapon_level = _weapon_level(wield) or 1
1413
- weapon_name = (
1414
- getattr(wield, "name", None)
1415
- or getattr(wield, "short_descr", None)
1416
- or "the weapon"
1417
- )
1399
+ weapon_name = getattr(wield, "name", None) or getattr(wield, "short_descr", None) or "the weapon"
1418
1400
  room = getattr(victim, "room", None)
1419
1401
 
1420
1402
  # WEAPON_POISON - ROM src/fight.c L600-634
@@ -1430,7 +1412,7 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
1430
1412
  )
1431
1413
  if hasattr(victim, "add_affect"):
1432
1414
  victim.add_affect(AffectFlag.POISON)
1433
- messages.append(f"The venom on {weapon_name} takes hold.")
1415
+ messages.append("You feel poison coursing through your veins.")
1434
1416
 
1435
1417
  # WEAPON_VAMPIRIC - ROM src/fight.c L640-649
1436
1418
  if weapon_flags & WEAPON_VAMPIRIC:
@@ -1451,7 +1433,7 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
1451
1433
  if hasattr(attacker, "alignment"):
1452
1434
  attacker.alignment = max(-1000, attacker.alignment - 1)
1453
1435
 
1454
- messages.append(f"{weapon_name} drains life.")
1436
+ messages.append(f"You feel {weapon_name} drawing your life away.")
1455
1437
 
1456
1438
  # WEAPON_FLAMING - ROM src/fight.c L651-659
1457
1439
  if weapon_flags & WEAPON_FLAMING:
@@ -1461,7 +1443,7 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
1461
1443
  room.broadcast(f"{victim.name} is burned by {weapon_name}.", exclude=victim)
1462
1444
  fire_effect(victim, weapon_level // 2, dam, SpellTarget.CHAR)
1463
1445
  apply_damage(attacker, victim, dam, DamageType.FIRE, show=False)
1464
- messages.append(f"{weapon_name} scorches {victim.name}.")
1446
+ messages.append(f"{weapon_name} sears your flesh.")
1465
1447
 
1466
1448
  # WEAPON_FROST - ROM src/fight.c L661-670
1467
1449
  if weapon_flags & WEAPON_FROST:
@@ -1471,7 +1453,7 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
1471
1453
  room.broadcast(f"{victim.name} is frozen by {weapon_name}.", exclude=victim)
1472
1454
  cold_effect(victim, weapon_level // 2, dam, SpellTarget.CHAR)
1473
1455
  apply_damage(attacker, victim, dam, DamageType.COLD, show=False)
1474
- messages.append(f"{weapon_name} chills {victim.name}.")
1456
+ messages.append("The cold touch surrounds you with ice.")
1475
1457
 
1476
1458
  # WEAPON_SHOCKING - ROM src/fight.c L672-681
1477
1459
  if weapon_flags & WEAPON_SHOCKING:
@@ -1484,6 +1466,6 @@ def process_weapon_special_attacks(attacker: Character, victim: Character) -> li
1484
1466
  )
1485
1467
  shock_effect(victim, weapon_level // 2, dam, SpellTarget.CHAR)
1486
1468
  apply_damage(attacker, victim, dam, DamageType.LIGHTNING, show=False)
1487
- messages.append(f"{weapon_name} shocks {victim.name}.")
1469
+ messages.append("You are shocked by the weapon.")
1488
1470
 
1489
1471
  return messages
mud/combat/safety.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ Combat safety checks - determine if it's safe to attack.
3
+
4
+ ROM Reference: src/fight.c is_safe
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from mud.models.character import Character
12
+
13
+
14
+ def is_safe(char: "Character", victim: "Character") -> bool:
15
+ """
16
+ Check if it's safe to attack victim (i.e., shouldn't attack).
17
+
18
+ ROM Reference: src/fight.c is_safe (lines 130-230)
19
+
20
+ Returns True if:
21
+ - Victim is in a SAFE room
22
+ - Victim is a shopkeeper
23
+ - Victim is a healer
24
+ - Attacker is too much lower level (for NPCs attacking players)
25
+ """
26
+ from mud.models.constants import RoomFlag, ActFlag
27
+
28
+ if char is None or victim is None:
29
+ return True
30
+
31
+ # Ghost can't fight
32
+ if getattr(char, "is_ghost", False):
33
+ return True
34
+
35
+ # Can't fight yourself
36
+ if char is victim:
37
+ return True
38
+
39
+ # Check for safe room
40
+ room = getattr(victim, "room", None)
41
+ if room:
42
+ room_flags = getattr(room, "room_flags", 0)
43
+ if room_flags & RoomFlag.ROOM_SAFE:
44
+ return True
45
+
46
+ # Check if victim is a shopkeeper or healer
47
+ victim_act = getattr(victim, "act", 0)
48
+ if getattr(victim, "is_npc", False):
49
+ # Check for special mob types that shouldn't be attacked
50
+ if victim_act & ActFlag.IS_HEALER:
51
+ return True
52
+ if victim_act & ActFlag.IS_CHANGER:
53
+ return True
54
+ if victim_act & ActFlag.TRAIN:
55
+ return True
56
+ if victim_act & ActFlag.PRACTICE:
57
+ return True
58
+ if victim_act & ActFlag.GAIN:
59
+ return True
60
+
61
+ # Check shop - if mob has a shop, it's a shopkeeper
62
+ if hasattr(victim, "pShop") and getattr(victim, "pShop", None):
63
+ return True
64
+
65
+ # NPC attacking much lower level player
66
+ if getattr(char, "is_npc", False) and not getattr(victim, "is_npc", True):
67
+ char_level = getattr(char, "level", 1)
68
+ victim_level = getattr(victim, "level", 1)
69
+ if victim_level < char_level - 10:
70
+ return True
71
+
72
+ return False
73
+
74
+
75
+ def is_safe_spell(char: "Character", victim: "Character", area: bool = False) -> bool:
76
+ """
77
+ Check if it's safe to cast an offensive spell on victim.
78
+
79
+ ROM Reference: src/fight.c is_safe_spell
80
+ """
81
+ # Can't spell yourself offensively in most cases
82
+ if char is victim and not area:
83
+ return True
84
+
85
+ # Use same logic as regular combat safety
86
+ return is_safe(char, victim)
87
+
88
+
89
+ def check_killer(char: "Character", victim: "Character") -> None:
90
+ """
91
+ Mark character as a killer if they attack an innocent.
92
+
93
+ ROM Reference: src/fight.c check_killer
94
+ """
95
+ from mud.models.constants import PlayerFlag
96
+
97
+ # Only applies to players
98
+ if getattr(char, "is_npc", True):
99
+ return
100
+
101
+ # NPCs don't make you a killer
102
+ if getattr(victim, "is_npc", True):
103
+ return
104
+
105
+ # Set KILLER flag
106
+ char.act = getattr(char, "act", 0) | PlayerFlag.KILLER
@@ -12,7 +12,7 @@ from mud.config import (
12
12
  from mud.models.character import Character, character_registry
13
13
  from mud.models.constants import CommFlag, PlayerFlag, Sex
14
14
  from mud.net.session import SESSIONS
15
- from mud.persistence import save_character as save_player_file
15
+ from mud.account.account_manager import save_character as save_player_file
16
16
  from mud.registry import room_registry
17
17
  from mud.security import bans
18
18
  from mud.security.bans import BanFlag, BanPermissionError
@@ -0,0 +1,98 @@
1
+ """
2
+ Affects command - show active spell/affect effects on character.
3
+
4
+ ROM Reference: src/act_info.c do_affects (lines 2300-2400)
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from mud.models.character import Character
9
+ from mud.models.constants import AffectFlag
10
+
11
+
12
+ # Mapping of affect flags to human-readable names
13
+ _AFFECT_NAMES = {
14
+ AffectFlag.BLIND: "blindness",
15
+ AffectFlag.INVISIBLE: "invisibility",
16
+ AffectFlag.DETECT_EVIL: "detect evil",
17
+ AffectFlag.DETECT_INVIS: "detect invisibility",
18
+ AffectFlag.DETECT_MAGIC: "detect magic",
19
+ AffectFlag.DETECT_HIDDEN: "detect hidden",
20
+ AffectFlag.DETECT_GOOD: "detect good",
21
+ AffectFlag.SANCTUARY: "sanctuary",
22
+ AffectFlag.FAERIE_FIRE: "faerie fire",
23
+ AffectFlag.INFRARED: "infrared vision",
24
+ AffectFlag.CURSE: "curse",
25
+ AffectFlag.POISON: "poison",
26
+ AffectFlag.PROTECT_EVIL: "protection evil",
27
+ AffectFlag.PROTECT_GOOD: "protection good",
28
+ AffectFlag.SNEAK: "sneak",
29
+ AffectFlag.HIDE: "hide",
30
+ AffectFlag.SLEEP: "sleep",
31
+ AffectFlag.CHARM: "charm",
32
+ AffectFlag.FLYING: "fly",
33
+ AffectFlag.PASS_DOOR: "pass door",
34
+ AffectFlag.HASTE: "haste",
35
+ AffectFlag.CALM: "calm",
36
+ AffectFlag.PLAGUE: "plague",
37
+ AffectFlag.WEAKEN: "weaken",
38
+ AffectFlag.DARK_VISION: "dark vision",
39
+ AffectFlag.BERSERK: "berserk",
40
+ AffectFlag.SWIM: "swim",
41
+ AffectFlag.REGENERATION: "regeneration",
42
+ AffectFlag.SLOW: "slow",
43
+ }
44
+
45
+
46
+ def do_affects(char: Character, args: str) -> str:
47
+ """
48
+ Display active affects on the character.
49
+
50
+ ROM Reference: src/act_info.c do_affects (lines 2300-2400)
51
+
52
+ Usage: affects
53
+ """
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")
64
+ 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:")
72
+ 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
+
98
+ return "\n".join(lines)