rom24-quickmud-python 2.4.2__py3-none-any.whl → 2.5.1__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.
mud/affects/saves.py CHANGED
@@ -90,7 +90,10 @@ def _check_immune(victim: Character, dam_type: int) -> int:
90
90
  immune = IS_IMMUNE
91
91
  elif (victim.res_flags & bit) and immune != IS_IMMUNE:
92
92
  immune = IS_RESISTANT
93
- elif victim.vuln_flags & bit:
93
+
94
+ # ROM C handler.c:306-314 - vuln check runs AFTER imm/res checks
95
+ # This is NOT elif - it runs independently to allow downgrading immunity
96
+ if victim.vuln_flags & bit:
94
97
  if immune == IS_IMMUNE:
95
98
  immune = IS_RESISTANT
96
99
  elif immune == IS_RESISTANT:
@@ -126,10 +129,7 @@ def saves_spell(level: int, victim: Character, dam_type: int) -> bool:
126
129
  save -= 2
127
130
 
128
131
  # Not NPC → apply fMana reduction if class gains mana
129
- if (
130
- not victim.is_npc
131
- and FMANA_BY_CLASS.get(victim.ch_class, False)
132
- ):
132
+ if not victim.is_npc and FMANA_BY_CLASS.get(victim.ch_class, False):
133
133
  save = c_div(9 * save, 10)
134
134
 
135
135
  save = urange(5, save, 95)
mud/ai/aggressive.py CHANGED
@@ -117,3 +117,8 @@ def aggressive_update() -> None:
117
117
  continue
118
118
 
119
119
  multi_hit(mob, victim)
120
+
121
+ # ROM src/fight.c:90 - Check for assist after combat starts
122
+ from mud.combat.assist import check_assist
123
+
124
+ check_assist(mob, victim)
mud/combat/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Combat engine utilities."""
2
2
 
3
+ from .assist import check_assist
3
4
  from .engine import attack_round, multi_hit
4
5
  from .messages import dam_message
5
6
 
6
- __all__ = ["attack_round", "multi_hit", "dam_message"]
7
+ __all__ = ["attack_round", "check_assist", "dam_message", "multi_hit"]
mud/combat/assist.py ADDED
@@ -0,0 +1,205 @@
1
+ """
2
+ Combat assist mechanics - auto-assist for group combat.
3
+
4
+ ROM Reference: src/fight.c check_assist (lines 105-181)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from mud.characters import is_same_group
12
+ from mud.combat.engine import multi_hit, is_good, is_evil, is_neutral
13
+ from mud.combat.safety import is_safe
14
+ from mud.models.constants import AffectFlag, OffFlag, PlayerFlag
15
+ from mud.utils import rng_mm
16
+ from mud.world.vision import can_see_character
17
+
18
+ if TYPE_CHECKING:
19
+ from mud.models.character import Character
20
+
21
+
22
+ def check_assist(ch: Character, victim: Character) -> None:
23
+ """
24
+ Check for auto-assist in combat following ROM C src/fight.c:check_assist (L105-181).
25
+
26
+ This function is called when 'ch' attacks 'victim' to see if anyone in the room
27
+ will automatically assist either side.
28
+
29
+ Handles six assist types:
30
+ 1. ASSIST_PLAYERS: Mobs help players fighting weaker mobs (lines 116-124)
31
+ 2. PLR_AUTOASSIST: Players auto-assist group members (lines 126-135)
32
+ 3. ASSIST_ALL: Mobs assist any mob in the room (line 141)
33
+ 4. ASSIST_RACE: Mobs assist same race (lines 143-144)
34
+ 5. ASSIST_ALIGN: Mobs assist same alignment (lines 145-148)
35
+ 6. ASSIST_VNUM: Mobs assist same vnum (lines 149-150)
36
+
37
+ Args:
38
+ ch: Character currently attacking (the aggressor)
39
+ victim: Character being attacked by ch
40
+
41
+ ROM C Reference:
42
+ src/fight.c:105-181
43
+ """
44
+ # Get all characters in the same room
45
+ room = getattr(ch, "room", None)
46
+ if not room:
47
+ return
48
+
49
+ people_in_room = getattr(room, "people", [])
50
+ if not people_in_room:
51
+ return
52
+
53
+ # Loop through all characters in the room
54
+ # NOTE: We need to be careful about list modification during iteration
55
+ # ROM uses rch_next pattern to handle this
56
+ for rch in list(people_in_room): # Create a copy to avoid modification issues
57
+ # Skip if not awake or already fighting
58
+ if not _is_awake(rch):
59
+ continue
60
+
61
+ if getattr(rch, "fighting", None) is not None:
62
+ continue
63
+
64
+ # --- ASSIST_PLAYERS: Mobs help players fighting weaker mobs (ROM lines 116-124) ---
65
+ if not _is_npc(ch) and _is_npc(rch):
66
+ rch_off_flags = getattr(rch, "off_flags", 0)
67
+ if rch_off_flags & OffFlag.ASSIST_PLAYERS:
68
+ rch_level = getattr(rch, "level", 1)
69
+ victim_level = getattr(victim, "level", 1)
70
+ if rch_level + 6 > victim_level:
71
+ _emote(rch, "screams and attacks!")
72
+ multi_hit(rch, victim, None)
73
+ continue
74
+
75
+ # --- PCs next (ROM lines 126-135) ---
76
+ # Player characters or charmed mobs can auto-assist
77
+ if not _is_npc(ch) or _is_affected(ch, AffectFlag.CHARM):
78
+ # Check if rch is a player with autoassist or a charmed mob
79
+ rch_act = getattr(rch, "act", 0)
80
+ is_rch_autoassist = not _is_npc(rch) and (rch_act & PlayerFlag.AUTOASSIST)
81
+ is_rch_charmed = _is_affected(rch, AffectFlag.CHARM)
82
+
83
+ if (is_rch_autoassist or is_rch_charmed) and is_same_group(ch, rch):
84
+ if not is_safe(rch, victim):
85
+ multi_hit(rch, victim, None)
86
+
87
+ continue
88
+
89
+ # --- NPC assist cases (ROM lines 137-178) ---
90
+ # Only NPCs that aren't charmed can use these assist types
91
+ if _is_npc(ch) and not _is_affected(ch, AffectFlag.CHARM):
92
+ if not _is_npc(rch):
93
+ continue # rch must be NPC for these assist types
94
+
95
+ rch_off_flags = getattr(rch, "off_flags", 0)
96
+ should_assist = False
97
+
98
+ # ASSIST_ALL: Assist any mob
99
+ if rch_off_flags & OffFlag.ASSIST_ALL:
100
+ should_assist = True
101
+
102
+ # ASSIST_RACE: Assist same race
103
+ elif rch_off_flags & OffFlag.ASSIST_RACE:
104
+ ch_race = getattr(ch, "race", None)
105
+ rch_race = getattr(rch, "race", None)
106
+ if ch_race and rch_race and ch_race == rch_race:
107
+ should_assist = True
108
+
109
+ # ASSIST_ALIGN: Assist same alignment
110
+ elif rch_off_flags & OffFlag.ASSIST_ALIGN:
111
+ if (
112
+ (is_good(rch) and is_good(ch))
113
+ or (is_evil(rch) and is_evil(ch))
114
+ or (is_neutral(rch) and is_neutral(ch))
115
+ ):
116
+ should_assist = True
117
+
118
+ # ASSIST_VNUM: Assist same vnum (same mob prototype)
119
+ elif rch_off_flags & OffFlag.ASSIST_VNUM:
120
+ ch_vnum = getattr(ch, "vnum", None)
121
+ rch_vnum = getattr(rch, "vnum", None)
122
+ if ch_vnum is not None and rch_vnum is not None and ch_vnum == rch_vnum:
123
+ should_assist = True
124
+
125
+ # Group assist: NPCs in same group help each other
126
+ ch_group = getattr(ch, "group", None)
127
+ rch_group = getattr(rch, "group", None)
128
+ if ch_group and rch_group and ch_group == rch_group:
129
+ should_assist = True
130
+
131
+ if should_assist:
132
+ # ROM lines 156-157: 50% chance to skip assist
133
+ if rng_mm.number_bits(1) == 0:
134
+ continue
135
+
136
+ # ROM lines 159-170: Randomly select target from victim's group
137
+ target = None
138
+ number = 0
139
+
140
+ # Use reservoir sampling to pick random group member
141
+ for vch in list(people_in_room):
142
+ if can_see_character(rch, vch) and is_same_group(vch, victim):
143
+ if rng_mm.number_range(0, number) == 0:
144
+ target = vch
145
+ number += 1
146
+
147
+ # ROM lines 172-176: Attack the selected target
148
+ if target is not None:
149
+ _emote(rch, "screams and attacks!")
150
+ multi_hit(rch, target, None)
151
+
152
+
153
+ def _is_awake(char: Character) -> bool:
154
+ """Check if character is awake (ROM IS_AWAKE macro)."""
155
+ from mud.models.constants import Position
156
+
157
+ position = getattr(char, "position", Position.STANDING)
158
+ return position > Position.SLEEPING
159
+
160
+
161
+ def _is_npc(char: Character) -> bool:
162
+ """Check if character is NPC (ROM IS_NPC macro)."""
163
+ return getattr(char, "is_npc", False)
164
+
165
+
166
+ def _is_affected(char: Character, flag: AffectFlag) -> bool:
167
+ """Check if character has affect flag (ROM IS_AFFECTED macro)."""
168
+ affected_by = getattr(char, "affected_by", 0)
169
+ return bool(affected_by & flag)
170
+
171
+
172
+ def _emote(char: Character, message: str) -> None:
173
+ """
174
+ Make character perform an emote.
175
+
176
+ ROM C uses: do_function(rch, &do_emote, "screams and attacks!");
177
+ Python equivalent: Send message to room.
178
+ """
179
+ from mud.models.social import expand_placeholders
180
+
181
+ room = getattr(char, "room", None)
182
+ if not room:
183
+ return
184
+
185
+ # Format: "Name screams and attacks!"
186
+ char_name = getattr(char, "name", "Someone")
187
+ full_message = f"{char_name} {message}"
188
+
189
+ # Send to everyone in the room
190
+ people = getattr(room, "people", [])
191
+ for person in people:
192
+ 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."""
198
+ send = getattr(char, "send", None)
199
+ if callable(send):
200
+ send(message + "\n")
201
+ else:
202
+ # Fallback: add to messages list if it exists
203
+ messages = getattr(char, "messages", None)
204
+ if isinstance(messages, list):
205
+ messages.append(message + "\n")
mud/combat/engine.py CHANGED
@@ -307,6 +307,11 @@ def multi_hit(attacker: Character, victim: Character, dt: str | int | None = Non
307
307
  if victim.position == Position.DEAD or not hasattr(attacker, "fighting") or attacker.fighting != victim:
308
308
  return results
309
309
 
310
+ # ROM src/fight.c:90 - Check for assist after first attack
311
+ from mud.combat.assist import check_assist
312
+
313
+ check_assist(attacker, victim)
314
+
310
315
  # ROM allows only a single strike for backstab.
311
316
  if _normalize_dt(dt) == "backstab":
312
317
  return results
@@ -475,6 +480,7 @@ def apply_damage(
475
480
 
476
481
  Handles:
477
482
  - Defense checks (parry, dodge, shield_block) - ROM checks these AFTER hit but BEFORE damage
483
+ - Damage type resistance/vulnerability (ROM fight.c:804-816)
478
484
  - Damage application and hit point reduction
479
485
  - Position updates based on remaining hit points
480
486
  - Fighting state management (set_fighting, stop_fighting)
@@ -504,6 +510,24 @@ def apply_damage(
504
510
  if check_dodge(attacker, victim):
505
511
  return f"{victim.name} dodges your attack."
506
512
 
513
+ # Apply damage type resistance/vulnerability modifiers (ROM fight.c:804-816)
514
+ # This must happen AFTER defense checks but BEFORE damage application
515
+ if dam_type is not None:
516
+ IS_IMMUNE = 1
517
+ IS_RESISTANT = 2
518
+ IS_VULNERABLE = 3
519
+
520
+ immune_check = _riv_check(victim, dam_type)
521
+ if immune_check == IS_IMMUNE:
522
+ immune = True
523
+ damage = 0
524
+ elif immune_check == IS_RESISTANT:
525
+ # ROM: dam -= dam / 3 (reduces damage by 33%)
526
+ damage -= c_div(damage, 3)
527
+ elif immune_check == IS_VULNERABLE:
528
+ # ROM: dam += dam / 2 (increases damage by 50%)
529
+ damage += c_div(damage, 2)
530
+
507
531
  message_bundle: DamageMessages | None = None
508
532
  if show:
509
533
  message_bundle = dam_message(attacker, victim, damage, dt, immune)
mud/commands/combat.py CHANGED
@@ -246,14 +246,17 @@ def do_rescue(char: Character, args: str) -> str:
246
246
 
247
247
 
248
248
  def _find_room_target(char: Character, name: str) -> Character | None:
249
+ """Find a character in the same room by name.
250
+
251
+ ROM Reference: src/act_info.c get_char_room()
252
+ Note: ROM allows finding self - individual commands check victim == ch.
253
+ """
249
254
  room = getattr(char, "room", None)
250
255
  if room is None or not name:
251
256
  return None
252
257
 
253
258
  lowered = name.lower()
254
259
  for candidate in getattr(room, "people", []) or []:
255
- if candidate is char:
256
- continue
257
260
  candidate_name = getattr(candidate, "name", "") or ""
258
261
  if lowered in candidate_name.lower():
259
262
  return candidate
@@ -529,19 +532,42 @@ def do_berserk(char: Character, args: str) -> str:
529
532
 
530
533
 
531
534
  def do_surrender(char: Character, args: str) -> str:
535
+ """
536
+ Surrender to opponent, ending combat.
537
+
538
+ ROM Reference: src/fight.c do_surrender (lines 3222-3242)
539
+
540
+ Allows a character to yield to their opponent. If surrendering to an NPC,
541
+ the NPC's TRIG_SURR mobprog is triggered if present. By default, NPCs
542
+ ignore surrender and continue attacking.
543
+ """
544
+ # Must be fighting (ROM fight.c:3225-3229)
532
545
  opponent = getattr(char, "fighting", None)
533
546
  if opponent is None:
534
- return "But you're not fighting!"
547
+ return "But you're not fighting!\n\r"
548
+
549
+ # Messages (ROM fight.c:3230-3232)
550
+ opponent_name = getattr(opponent, "name", "someone")
551
+ messages = [f"You surrender to {opponent_name}!"]
535
552
 
553
+ # Stop fighting (ROM fight.c:3233 - stop_fighting(ch, TRUE))
536
554
  stop_fighting(char, True)
537
555
  if getattr(opponent, "fighting", None) is char:
538
556
  opponent.fighting = None
539
557
 
558
+ # Check for TRIG_SURR mobprog if player surrendering to NPC
559
+ # ROM fight.c:3235-3241
540
560
  if not getattr(char, "is_npc", False) and getattr(opponent, "is_npc", False):
561
+ # Try to trigger surrender mobprog
541
562
  if not mobprog.mp_surr_trigger(opponent, char):
542
- multi_hit(opponent, char)
563
+ # No mobprog or mobprog didn't handle it - NPC ignores and attacks
564
+ messages.append(f"{opponent_name} seems to ignore your cowardly act!")
565
+ # ROM: multi_hit(mob, ch, TYPE_UNDEFINED)
566
+ attack_messages = multi_hit(opponent, char)
567
+ if attack_messages:
568
+ messages.extend(attack_messages)
543
569
 
544
- return "You surrender."
570
+ return "\n".join(messages) if len(messages) > 1 else messages[0]
545
571
 
546
572
 
547
573
  def do_flee(char: Character, args: str) -> str:
@@ -571,10 +597,10 @@ def do_flee(char: Character, args: str) -> str:
571
597
  dex = char.get_curr_stat(Stat.DEX)
572
598
  else:
573
599
  dex = 13 # Default dex
574
-
600
+
575
601
  if dex is None:
576
602
  dex = 13
577
-
603
+
578
604
  chance = 50 + (dex - 13) * 5 # Base 50%, +/- 5% per dex point
579
605
 
580
606
  # Reduce chance if badly hurt
@@ -597,17 +623,17 @@ def do_flee(char: Character, args: str) -> str:
597
623
  for direction, exit_data in exits.items():
598
624
  if exit_data:
599
625
  # Handle both dict-style and Exit object style
600
- if hasattr(exit_data, 'exit_info'):
626
+ if hasattr(exit_data, "exit_info"):
601
627
  # Exit object
602
- closed = bool(getattr(exit_data, 'exit_info', 0) & 1) # EX_ISDOOR and closed
603
- to_room = getattr(exit_data, 'to_room', None)
628
+ closed = bool(getattr(exit_data, "exit_info", 0) & 1) # EX_ISDOOR and closed
629
+ to_room = getattr(exit_data, "to_room", None)
604
630
  elif isinstance(exit_data, dict):
605
631
  # Dict style
606
632
  closed = exit_data.get("closed", False)
607
633
  to_room = exit_data.get("to_room")
608
634
  else:
609
635
  continue
610
-
636
+
611
637
  if not closed and to_room:
612
638
  valid_exits.append((direction, to_room))
613
639
 
@@ -754,16 +780,16 @@ def do_cast(char: Character, args: str) -> str:
754
780
  def do_dirt(char: Character, args: str) -> str:
755
781
  """
756
782
  Kick dirt in opponent's eyes to blind them.
757
-
783
+
758
784
  ROM Reference: src/fight.c do_dirt (lines 2489-2640)
759
785
  """
760
786
  target_name = (args or "").strip()
761
-
787
+
762
788
  # Check if character has the skill
763
789
  skill_level = char.skills.get("dirt kicking", 0)
764
790
  if skill_level == 0:
765
791
  return "You get your feet dirty."
766
-
792
+
767
793
  # Find target
768
794
  if not target_name:
769
795
  victim = getattr(char, "fighting", None)
@@ -773,63 +799,38 @@ def do_dirt(char: Character, args: str) -> str:
773
799
  victim = _find_room_target(char, target_name)
774
800
  if victim is None:
775
801
  return "They aren't here."
776
-
777
- # Check if already blinded
802
+
803
+ # Match ROM safety/validation gates in the command layer.
804
+ if victim is char:
805
+ return "Very funny."
806
+
778
807
  victim_affected = getattr(victim, "affected_by", 0)
779
808
  if victim_affected & AffectFlag.BLIND:
780
809
  return "They're already blinded."
781
-
782
- if victim is char:
783
- return "Very funny."
784
-
785
- # Safety checks
810
+
786
811
  safety_msg = _kill_safety_message(char, victim)
787
812
  if safety_msg:
788
813
  return safety_msg
789
-
790
- # Calculate chance
791
- chance = skill_level
792
- char_dex = skill_handlers._coerce_int(getattr(char, "perm_stat", [13]*5)[1] if isinstance(getattr(char, "perm_stat", []), list) else 13)
793
- victim_dex = skill_handlers._coerce_int(getattr(victim, "perm_stat", [13]*5)[1] if isinstance(getattr(victim, "perm_stat", []), list) else 13)
794
- chance += char_dex - 2 * victim_dex
795
-
796
- # Level modifier
797
- char_level = skill_handlers._coerce_int(getattr(char, "level", 1))
798
- victim_level = skill_handlers._coerce_int(getattr(victim, "level", 1))
799
- chance += (char_level - victim_level) * 2
800
-
801
- # Roll
802
- if rng_mm.number_percent() < chance:
803
- # Success - blind the victim
804
- victim.affected_by = victim_affected | AffectFlag.BLIND
805
- skill_registry._apply_wait_state(char, get_pulse_violence())
806
-
807
- # Start combat if not already fighting
808
- if not getattr(char, "fighting", None):
809
- char.fighting = victim
810
- if not getattr(victim, "fighting", None):
811
- victim.fighting = char
812
-
813
- check_killer(char, victim)
814
- return f"You kick dirt into {getattr(victim, 'name', 'their')} eyes!"
815
- else:
816
- skill_registry._apply_wait_state(char, get_pulse_violence())
817
- return "You kick dirt but miss their eyes."
814
+
815
+ # Delegate parity math/effects to the skill handler.
816
+ result = skill_handlers.dirt_kicking(char, target=victim)
817
+ check_killer(char, victim)
818
+ return result
818
819
 
819
820
 
820
821
  def do_trip(char: Character, args: str) -> str:
821
822
  """
822
823
  Trip opponent to knock them down.
823
-
824
+
824
825
  ROM Reference: src/fight.c do_trip (lines 2641-2760)
825
826
  """
826
827
  target_name = (args or "").strip()
827
-
828
+
828
829
  # Check if character has the skill
829
830
  skill_level = char.skills.get("trip", 0)
830
831
  if skill_level == 0:
831
832
  return "Tripping? What's that?"
832
-
833
+
833
834
  # Find target
834
835
  if not target_name:
835
836
  victim = getattr(char, "fighting", None)
@@ -839,56 +840,60 @@ def do_trip(char: Character, args: str) -> str:
839
840
  victim = _find_room_target(char, target_name)
840
841
  if victim is None:
841
842
  return "They aren't here."
842
-
843
+
843
844
  # Safety checks
844
845
  safety_msg = _kill_safety_message(char, victim)
845
846
  if safety_msg:
846
847
  return safety_msg
847
-
848
+
848
849
  # Can't trip flying targets
849
850
  victim_affected = getattr(victim, "affected_by", 0)
850
851
  if victim_affected & AffectFlag.FLYING:
851
852
  return "Their feet aren't on the ground."
852
-
853
+
853
854
  # Can't trip someone already down
854
855
  victim_pos = getattr(victim, "position", Position.STANDING)
855
856
  if victim_pos < Position.FIGHTING:
856
857
  return "They are already down."
857
-
858
+
858
859
  if victim is char:
859
860
  skill_registry._apply_wait_state(char, get_pulse_violence() * 2)
860
861
  return "You fall flat on your face!"
861
-
862
+
862
863
  # Calculate chance
863
864
  chance = skill_level
864
-
865
+
865
866
  # Size modifier
866
867
  char_size = skill_handlers._coerce_int(getattr(char, "size", 2))
867
868
  victim_size = skill_handlers._coerce_int(getattr(victim, "size", 2))
868
869
  if char_size < victim_size:
869
870
  chance += (char_size - victim_size) * 10
870
-
871
+
871
872
  # Dex modifier
872
- char_dex = skill_handlers._coerce_int(getattr(char, "perm_stat", [13]*5)[1] if isinstance(getattr(char, "perm_stat", []), list) else 13)
873
- victim_dex = skill_handlers._coerce_int(getattr(victim, "perm_stat", [13]*5)[1] if isinstance(getattr(victim, "perm_stat", []), list) else 13)
873
+ char_dex = skill_handlers._coerce_int(
874
+ getattr(char, "perm_stat", [13] * 5)[1] if isinstance(getattr(char, "perm_stat", []), list) else 13
875
+ )
876
+ victim_dex = skill_handlers._coerce_int(
877
+ getattr(victim, "perm_stat", [13] * 5)[1] if isinstance(getattr(victim, "perm_stat", []), list) else 13
878
+ )
874
879
  chance += char_dex - victim_dex * 3 // 2
875
-
880
+
876
881
  # Level modifier
877
882
  char_level = skill_handlers._coerce_int(getattr(char, "level", 1))
878
883
  victim_level = skill_handlers._coerce_int(getattr(victim, "level", 1))
879
884
  chance += (char_level - victim_level) * 2
880
-
885
+
881
886
  # Roll
882
887
  if rng_mm.number_percent() < chance:
883
888
  # Success
884
889
  victim.position = Position.RESTING
885
890
  skill_registry._apply_wait_state(char, get_pulse_violence())
886
891
  skill_registry._apply_wait_state(victim, get_pulse_violence() * 2)
887
-
892
+
888
893
  # Damage
889
894
  damage_amt = rng_mm.number_range(2, 2 + 2 * victim_size + skill_level // 20)
890
895
  apply_damage(char, victim, damage_amt, DamageType.BASH)
891
-
896
+
892
897
  check_killer(char, victim)
893
898
  return f"You trip {getattr(victim, 'name', 'them')} and they go down!"
894
899
  else:
@@ -899,67 +904,34 @@ def do_trip(char: Character, args: str) -> str:
899
904
  def do_disarm(char: Character, args: str) -> str:
900
905
  """
901
906
  Attempt to disarm opponent's weapon.
902
-
907
+
903
908
  ROM Reference: src/fight.c do_disarm (lines 3145-3220)
904
909
  """
905
910
  # Check if character has the skill
906
911
  skill_level = char.skills.get("disarm", 0)
907
912
  if skill_level == 0:
908
913
  return "You don't know how to disarm opponents."
909
-
914
+
910
915
  # Must be fighting
911
916
  victim = getattr(char, "fighting", None)
912
917
  if victim is None:
913
918
  return "You aren't fighting anyone."
914
-
919
+
920
+ # Attacker must have a weapon, or meet ROM hand-to-hand / NPC OFF_DISARM exception.
921
+ caster_weapon = get_wielded_weapon(char)
922
+ hth_skill = char.skills.get("hand to hand", 0)
923
+ if caster_weapon is None and hth_skill == 0:
924
+ return "You must wield a weapon to disarm."
925
+
915
926
  # Victim must be wielding a weapon
916
- victim_equipped = getattr(victim, "equipped", {})
917
- victim_weapon = victim_equipped.get("wield") or victim_equipped.get("main_hand")
927
+ victim_weapon = get_wielded_weapon(victim)
918
928
  if victim_weapon is None:
919
929
  return "Your opponent is not wielding a weapon."
920
-
921
- # Attacker should have weapon (or hand-to-hand skill)
922
- char_equipped = getattr(char, "equipped", {})
923
- char_weapon = char_equipped.get("wield") or char_equipped.get("main_hand")
924
- hth_skill = char.skills.get("hand to hand", 0)
925
-
926
- if char_weapon is None and hth_skill == 0:
927
- return "You must wield a weapon to disarm."
928
-
929
- # Calculate chance
930
- if char_weapon is None:
931
- chance = skill_level * hth_skill // 150
932
- else:
933
- chance = skill_level
934
-
935
- # Dex vs Str
936
- char_dex = skill_handlers._coerce_int(getattr(char, "perm_stat", [13]*5)[1] if isinstance(getattr(char, "perm_stat", []), list) else 13)
937
- victim_str = skill_handlers._coerce_int(getattr(victim, "perm_stat", [13]*5)[0] if isinstance(getattr(victim, "perm_stat", []), list) else 13)
938
- chance += char_dex - 2 * victim_str
939
-
940
- # Level modifier
941
- char_level = skill_handlers._coerce_int(getattr(char, "level", 1))
942
- victim_level = skill_handlers._coerce_int(getattr(victim, "level", 1))
943
- chance += (char_level - victim_level) * 2
944
-
945
- skill_registry._apply_wait_state(char, get_pulse_violence())
946
-
947
- # Roll
948
- if rng_mm.number_percent() < chance:
949
- # Success - remove weapon from victim
950
- if "wield" in victim_equipped:
951
- del victim_equipped["wield"]
952
- elif "main_hand" in victim_equipped:
953
- del victim_equipped["main_hand"]
954
-
955
- # Drop to room
956
- victim_room = getattr(victim, "room", None)
957
- if victim_room and hasattr(victim_room, "contents"):
958
- victim_room.contents.append(victim_weapon)
959
- victim_weapon.in_room = victim_room
960
-
961
- check_killer(char, victim)
962
- return f"You disarm {getattr(victim, 'name', 'them')}!"
963
- else:
964
- return f"You fail to disarm {getattr(victim, 'name', 'them')}."
965
930
 
931
+ success = skill_handlers.disarm(char, target=victim)
932
+ check_killer(char, victim)
933
+
934
+ victim_name = getattr(victim, "name", "them")
935
+ if success:
936
+ return f"You disarm {victim_name}!"
937
+ return f"You fail to disarm {victim_name}."