rom24-quickmud-python 2.14.211__py3-none-any.whl → 2.14.268__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.
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import UTC, datetime
3
+ from datetime import datetime, timezone
4
4
  from pathlib import Path
5
5
  from typing import TYPE_CHECKING
6
6
 
@@ -100,7 +100,7 @@ def log_admin_command(
100
100
  """
101
101
 
102
102
  Path("log").mkdir(exist_ok=True)
103
- timestamp = datetime.now(UTC).isoformat().replace("+00:00", "Z")
103
+ timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
104
104
  sanitized = _sanitize_command_line(command_line)
105
105
  if character is not None:
106
106
  try:
@@ -132,7 +132,7 @@ def rotate_admin_log(today: datetime | None = None) -> Path:
132
132
  active = log_dir / "admin.log"
133
133
  if not active.exists():
134
134
  return active
135
- dt = today or datetime.now(UTC)
135
+ dt = today or datetime.now(timezone.utc)
136
136
  dated = log_dir / f"admin-{dt.strftime('%Y%m%d')}.log"
137
137
  # Avoid clobbering: if dated file exists, append current log and remove active
138
138
  if dated.exists():
mud/combat/engine.py CHANGED
@@ -40,10 +40,11 @@ from mud.models.constants import (
40
40
  )
41
41
  from mud.models.weapon_table import weapon_skill_name_for_type
42
42
  from mud.skills import check_improve
43
+ from mud.skills.skill_lookup import get_skill
43
44
  from mud.utils import rng_mm
44
45
  from mud.utils.act import capitalize_act_line
45
46
  from mud.wiznet import WiznetFlag, wiznet
46
- from mud.world.vision import can_see_object, pers
47
+ from mud.world.vision import can_see_character, can_see_object, pers
47
48
 
48
49
  HAND_TO_HAND_SKILL = "hand to hand"
49
50
 
@@ -519,6 +520,35 @@ def _mob_offensive_skill(attacker: Character, roll: int, off_flags: int, act_fla
519
520
  # commented out).
520
521
 
521
522
 
523
+ def _compute_victim_ac(attacker: Character, victim: Character, ac_idx: int, victim_pos: int) -> int:
524
+ """ROM ``one_hit`` effective victim AC (src/fight.c:480-503).
525
+
526
+ ROM divides GET_AC by 10 **first**, then applies the ``< -15`` rescale and
527
+ the visibility/position modifiers **on the /10 scale** — not on the raw
528
+ (×10) armor value. FIGHT-081: the pre-fix Python applied the modifiers and
529
+ the rescale to the raw AC and divided by 10 last, making the -4/+4/+6
530
+ modifiers ~10× too weak and mis-firing the rescale for nearly every armored
531
+ character. The visibility -4 gates on ``!can_see(ch, victim)`` (broader than
532
+ the victim's INVISIBLE affect: also blind/dark/hide, and withheld from a
533
+ detect-invis attacker), mirroring ROM src/fight.c:496.
534
+ """
535
+
536
+ # mirroring ROM src/fight.c:480-491 — GET_AC(victim, ac) / 10 (c_div: AC is signed).
537
+ victim_ac = c_div(get_ac(victim, ac_idx), 10)
538
+ # mirroring ROM src/fight.c:493-494 — rescale of very-negative AC, on the /10 scale.
539
+ if victim_ac < -15:
540
+ victim_ac = c_div(victim_ac + 15, 5) - 15
541
+ # mirroring ROM src/fight.c:496-497 — !can_see(ch, victim), not the INVISIBLE affect.
542
+ if not can_see_character(attacker, victim):
543
+ victim_ac -= 4
544
+ # mirroring ROM src/fight.c:499-503 — position penalties on the /10 scale.
545
+ if victim_pos < Position.FIGHTING:
546
+ victim_ac += 4
547
+ if victim_pos < Position.RESTING:
548
+ victim_ac += 6
549
+ return victim_ac
550
+
551
+
522
552
  def attack_round(attacker: Character, victim: Character, dt: str | int | None = None) -> str:
523
553
  """Resolve a single attack round.
524
554
 
@@ -548,19 +578,10 @@ def attack_round(attacker: Character, victim: Character, dt: str | int | None =
548
578
  dam_type_lookup = attack_damage_type(attack_index)
549
579
  dam_type = int(dam_type_lookup) if dam_type_lookup is not None else int(DamageType.BASH)
550
580
  ac_idx = ac_index_for_dam_type(dam_type)
551
- # ROM src/merc.h:2104-2106 GET_AC adds dex_app[DEX].defensive when IS_AWAKE.
552
- victim_ac = get_ac(victim, ac_idx)
553
- # Visibility and position modifiers (ROM-inspired)
554
- if getattr(victim, "has_affect", None) and victim.has_affect(AffectFlag.INVISIBLE):
555
- victim_ac -= 4
556
- if _victim_pos_before < Position.FIGHTING:
557
- victim_ac += 4
558
- if _victim_pos_before < Position.RESTING:
559
- victim_ac += 6
560
-
561
- # ROM AC clamping for very negative values
562
- if victim_ac < -15:
563
- victim_ac = c_div(victim_ac + 15, 5) - 15
581
+ # FIGHT-081: ROM one_hit (src/fight.c:480-503) divides GET_AC by 10 first,
582
+ # then rescales/modifies on the /10 scale. victim_ac is already the value
583
+ # used directly in the hit test — no further division.
584
+ victim_ac = _compute_victim_ac(attacker, victim, ac_idx, _victim_pos_before)
564
585
 
565
586
  # FIGHT-019: ROM one_hit has a single attack-roll model — the THAC0 /
566
587
  # number_bits(5) roll (src/fight.c:508-516). The legacy percent model
@@ -593,9 +614,16 @@ def attack_round(attacker: Character, victim: Character, dt: str | int | None =
593
614
  )
594
615
  else:
595
616
  th = compute_thac0(attacker.level, attacker.ch_class, hitroll=get_hitroll(attacker), skill=skill_total)
596
- vac = c_div(victim_ac, 10)
617
+ # FIGHT-086 / HANDLER-008: ROM one_hit (src/fight.c:474-475) — backstab
618
+ # near-auto-hit below skill 100: thac0 -= 10 * (100 - get_skill(ch, gsn_backstab)).
619
+ # get_skill clamps to 0..100, so 100-skill is non-negative here. No c_div — no
620
+ # division. Migrated from the _backstab_skill partial mirror onto unified get_skill.
621
+ if _normalize_dt(dt) == "backstab":
622
+ th -= 10 * (100 - get_skill(attacker, "backstab"))
623
+ # FIGHT-081: victim_ac is already on the /10 scale (see _compute_victim_ac); no
624
+ # further division — ROM src/fight.c:510 uses victim_ac directly.
597
625
  # ROM src/fight.c:510 — miss on nat 0, or (not 19 and diceroll < thac0 - victim_ac).
598
- if diceroll == 0 or (diceroll != 19 and diceroll < (th - vac)):
626
+ if diceroll == 0 or (diceroll != 19 and diceroll < (th - victim_ac)):
599
627
  # Miss — ROM C calls damage(ch, victim, 0, dt, dam_type, TRUE).
600
628
  return apply_damage(attacker, victim, 0, int(dam_type), dt=attack_dt)
601
629
 
@@ -712,6 +740,19 @@ def apply_damage(
712
740
  if check_shield_block(attacker, victim):
713
741
  return capitalize_act_line(f"{pers(victim, attacker)} blocks your attack with a shield.")
714
742
 
743
+ # FIGHT-092 — "Inviso attacks ... not." ROM src/fight.c:763-769: a connecting
744
+ # hit reveals an invisible attacker. Strip the invis/mass_invis spell affects
745
+ # and the AFF_INVISIBLE bit, then broadcast "$n fades into existence." to the
746
+ # room. Placed past the parry/dodge/shield-block early-returns (mirroring ROM,
747
+ # where parry/dodge resolve in one_hit before damage() is entered) and before
748
+ # the RIV/immune modifiers, so it fires even on a 0-damage (immune) hit.
749
+ if attacker.has_affect(AffectFlag.INVISIBLE):
750
+ if hasattr(attacker, "remove_spell_effect"):
751
+ attacker.remove_spell_effect("invis")
752
+ attacker.remove_spell_effect("mass invis")
753
+ attacker.affected_by = int(getattr(attacker, "affected_by", 0) or 0) & ~int(AffectFlag.INVISIBLE)
754
+ _broadcast_pos_change(attacker, "{name} fades into existence.")
755
+
715
756
  # Apply damage type resistance/vulnerability modifiers (ROM fight.c:804-816)
716
757
  # This must happen AFTER defense checks but BEFORE damage application
717
758
  if dam_type is not None:
@@ -1547,7 +1588,10 @@ def check_shield_block(attacker: Character, victim: Character) -> bool:
1547
1588
  if not _has_shield_equipped(victim):
1548
1589
  return False
1549
1590
 
1550
- shield_skill = _get_skill_percent(victim, "shield block", "shield_block_skill")
1591
+ # HANDLER-008: unified get_skill — NPCs get ROM's 10+2*level shield-block
1592
+ # formula (src/handler.c:373-432); the old dict lookup returned 0 for mobs, so
1593
+ # NPCs could never shield-block. PCs gate to 0 below the class skill level.
1594
+ shield_skill = get_skill(victim, "shield block")
1551
1595
  chance = c_div(shield_skill, 5) + 3
1552
1596
 
1553
1597
  # Level difference modifier
@@ -1580,7 +1624,9 @@ def check_parry(attacker: Character, victim: Character) -> bool:
1580
1624
  if not is_awake(victim):
1581
1625
  return False
1582
1626
 
1583
- parry_skill = _get_skill_percent(victim, "parry", "parry_skill")
1627
+ # HANDLER-008: unified get_skill — NPCs with OFF_PARRY get ROM's level*2
1628
+ # (src/handler.c:373-432); PCs gate to 0 below the parry class level.
1629
+ parry_skill = get_skill(victim, "parry")
1584
1630
  chance = c_div(parry_skill, 2)
1585
1631
 
1586
1632
  has_weapon = getattr(victim, "has_weapon_equipped", False)
@@ -1590,8 +1636,11 @@ def check_parry(attacker: Character, victim: Character) -> bool:
1590
1636
  else:
1591
1637
  return False # PCs need weapons to parry
1592
1638
 
1593
- # Visibility modifier
1594
- if not getattr(victim, "can_see", lambda x: True)(attacker):
1639
+ # Visibility modifier — FIGHT-084: ROM src/fight.c:1311 is `!can_see(ch, victim)`
1640
+ # (attacker→victim), NOT victim→attacker. The pre-fix `victim.can_see(attacker)`
1641
+ # was both the wrong direction and inert (no can_see method → `lambda x: True`
1642
+ # fallback never halved). check_dodge is the opposite direction (ROM :1363).
1643
+ if not can_see_character(attacker, victim):
1595
1644
  chance = c_div(chance, 2)
1596
1645
 
1597
1646
  # Level difference modifier
@@ -1623,11 +1672,17 @@ def check_dodge(attacker: Character, victim: Character) -> bool:
1623
1672
  if not is_awake(victim):
1624
1673
  return False
1625
1674
 
1626
- dodge_skill = _get_skill_percent(victim, "dodge", "dodge_skill")
1675
+ # HANDLER-008: unified get_skill — NPCs with OFF_DODGE get ROM's level*2
1676
+ # (src/handler.c:373-432); PCs gate to 0 below the dodge class level.
1677
+ dodge_skill = get_skill(victim, "dodge")
1627
1678
  chance = c_div(dodge_skill, 2)
1628
1679
 
1629
- # Visibility modifier
1630
- if not getattr(victim, "can_see", lambda x: True)(attacker):
1680
+ # Visibility modifier — ROM src/fight.c:1363: if (!can_see(victim, ch)) chance /= 2.
1681
+ # Direction is victim→attacker (defender's view of the attacker); can_see is not
1682
+ # symmetric. FIGHT-089 (twin of the FIGHT-084 check_parry fix): the old
1683
+ # getattr(victim, "can_see", lambda x: True) fallback was inert — runtime entities
1684
+ # have no can_see method, so the halving never fired.
1685
+ if not can_see_character(victim, attacker):
1631
1686
  chance = c_div(chance, 2)
1632
1687
 
1633
1688
  # Level difference modifier
@@ -165,7 +165,12 @@ def do_practice(char: Character, args: str) -> str:
165
165
  if required_level is not None:
166
166
  if required_level >= LEVEL_IMMORTAL:
167
167
  return "You can't practice that."
168
- if current <= 0 and char.level < required_level:
168
+ # PRACTICE-002: ROM src/act_info.c:2744-2757 gates on
169
+ # `ch->level < skill_table[sn].skill_level[class]` UNCONDITIONALLY (part of
170
+ # the "You can't practice that." OR) — a below-level character cannot
171
+ # practice a skill even when it is already known at >=1%. The old
172
+ # `current <= 0 and` qualifier let a known-but-too-high skill be practiced.
173
+ if char.level < required_level:
169
174
  return "You can't practice that."
170
175
 
171
176
  rating = _rating_for_class(skill, char.ch_class)
mud/commands/affects.py CHANGED
@@ -147,8 +147,11 @@ def do_affects(char: Character, args: str) -> str:
147
147
  # Deduplication: check if same spell as previous affect
148
148
  if paf_last and paf.type == paf_last.type:
149
149
  if char.level >= 20:
150
- # Level 20+: Show duplicate affects with indentation (22 spaces + ": ")
151
- buf = " " * 22 + ": "
150
+ # AFFECTS-001: ROM src/act_info.c:1726 emits exactly 22 spaces (no
151
+ # colon) for a duplicate affect; the ": modifies …" detail block
152
+ # below supplies the single colon (matching "Spell: %-15s" width).
153
+ # The old `+ ": "` produced a double colon (": :").
154
+ buf = " " * 22
152
155
  else:
153
156
  # Level <20: Skip duplicate spells entirely
154
157
  continue
mud/commands/combat.py CHANGED
@@ -25,6 +25,7 @@ from mud.models.constants import (
25
25
  )
26
26
  from mud.skills import skill_registry
27
27
  from mud.skills.say_spell import broadcast_spell_words
28
+ from mud.skills.skill_lookup import get_skill
28
29
  from mud.utils import rng_mm
29
30
  from mud.utils.act import act_format, act_to_room
30
31
  from mud.world.vision import can_see_character
@@ -216,11 +217,10 @@ def do_kick(char: Character, args: str) -> str:
216
217
  cooldowns["kick"] = skill.cooldown
217
218
  char.cooldowns = cooldowns
218
219
  roll = rng_mm.number_percent()
219
- try:
220
- learned = getattr(char, "skills", {}).get("kick", 0)
221
- chance = max(0, min(100, int(learned)))
222
- except (TypeError, ValueError):
223
- chance = 0
220
+ # FIGHT-091 / HANDLER-008: ROM do_kick rolls get_skill(ch, gsn_kick)
221
+ # (src/fight.c:3125) — NPC 10+3*level, PC class-gated learned, both
222
+ # daze/drunk-adjusted. Migrated from the inline NPC-formula workaround.
223
+ chance = get_skill(char, "kick")
224
224
  success = chance > roll
225
225
 
226
226
  message = skill_handlers.kick(char, opponent, success=success, roll=roll)
@@ -284,7 +284,10 @@ def do_rescue(char: Character, args: str) -> str:
284
284
  char.cooldowns = cooldowns
285
285
 
286
286
  roll = rng_mm.number_percent()
287
- learned = _character_skill_percent(char, "rescue")
287
+ # HANDLER-008: ROM do_rescue rolls get_skill(ch, gsn_rescue) (src/fight.c:3082) —
288
+ # PC class-gated learned, NPC 40+level (was 0 via the dict → NPC rescue never
289
+ # worked), daze/drunk-adjusted.
290
+ learned = get_skill(char, "rescue")
288
291
  success = roll <= learned
289
292
 
290
293
  if not success:
@@ -1173,29 +1176,38 @@ def do_dirt(char: Character, args: str) -> str:
1173
1176
  return result
1174
1177
 
1175
1178
 
1176
- def do_trip(char: Character, args: str) -> str:
1179
+ def do_trip(char: Character, args: str, *, victim: Character | None = None) -> str:
1177
1180
  """
1178
1181
  Trip opponent to knock them down.
1179
1182
 
1180
1183
  ROM Reference: src/fight.c do_trip (lines 2641-2760)
1184
+
1185
+ FIGHT-090: this is the single canonical trip implementation. The ``victim``
1186
+ keyword lets the skill-registry ``trip`` handler (``skill_handlers.trip``)
1187
+ delegate here with an explicit target instead of maintaining a divergent copy.
1181
1188
  """
1182
1189
  target_name = (args or "").strip()
1183
1190
 
1184
- # Check if character has the skill. ROM src/fight.c do_trip: NPCs gate on
1185
- # OFF_TRIP (no skills dict), PCs on the learned percent. For an NPC ROM's
1186
- # get_skill returns a nonzero value; mirror the do_kick/do_berserk NPC
1187
- # pattern and treat the skill as fully learned (100).
1191
+ # Check if character has the skill. ROM src/fight.c:2649 do_trip: NPCs gate on
1192
+ # OFF_TRIP (no skills dict), PCs on the learned percent. HANDLER-008: ROM's
1193
+ # `chance = get_skill(ch, gsn_trip)` gives an OFF_TRIP NPC `10 + 3*level`
1194
+ # (src/handler.c:373-432), NOT 100 the old hardcode let a low-level mob trip
1195
+ # far too often. The OFF_TRIP gate stays (get_skill's NPC dispatch returns 0
1196
+ # without the flag, but the explicit gate mirrors ROM's IS_SET check + the ""
1197
+ # silent return for descriptor-less mobs).
1188
1198
  if getattr(char, "is_npc", False):
1189
1199
  if not _npc_off_flags(char) & OffFlag.TRIP:
1190
1200
  return ""
1191
- skill_level = 100
1201
+ skill_level = get_skill(char, "trip")
1192
1202
  else:
1193
1203
  skill_level = _character_skill_percent(char, "trip")
1194
1204
  if skill_level == 0:
1195
1205
  return "Tripping? What's that?"
1196
1206
 
1197
- # Find target
1198
- if not target_name:
1207
+ # Find target (skip when an explicit victim is supplied via delegation).
1208
+ if victim is not None:
1209
+ pass
1210
+ elif not target_name:
1199
1211
  victim = getattr(char, "fighting", None)
1200
1212
  if victim is None:
1201
1213
  return "But you aren't fighting anyone!"
@@ -1221,19 +1233,38 @@ def do_trip(char: Character, args: str) -> str:
1221
1233
  ):
1222
1234
  return "Kill stealing is not permitted."
1223
1235
 
1224
- # Can't trip flying targets
1236
+ # Can't trip flying targets — ROM src/fight.c:2686 act("$S feet aren't on the
1237
+ # ground.", ch, NULL, victim, TO_CHAR); $S = victim's possessive pronoun.
1238
+ # FIGHT-090: was a baked "Their" here (the PERS render was only on
1239
+ # skill_handlers.trip via TRIP-001); merged in so do_trip is canonical.
1225
1240
  victim_affected = getattr(victim, "affected_by", 0)
1226
1241
  if victim_affected & AffectFlag.FLYING:
1227
- return "Their feet aren't on the ground."
1242
+ return act_format("$S feet aren't on the ground.", recipient=char, actor=char, arg2=victim)
1228
1243
 
1229
- # Can't trip someone already down
1244
+ # Can't trip someone already down — ROM src/fight.c:2689 act("$N is already
1245
+ # down.", …, TO_CHAR); $N = PERS(victim). FIGHT-090 (was baked "They are").
1230
1246
  victim_pos = getattr(victim, "position", Position.STANDING)
1231
1247
  if victim_pos < Position.FIGHTING:
1232
- return "They are already down."
1248
+ return act_format("$N is already down.", recipient=char, actor=char, arg2=victim)
1249
+
1250
+ # FIGHT-082: ROM src/fight.c:2700/2742/2750 — do_trip WAIT_STATE uses the
1251
+ # skill's RAW beats (skill_table[gsn_trip].beats == 24), not PULSE_VIOLENCE,
1252
+ # with no HASTE/SLOW adjustment (read skill.beats directly, per CAST-010; the
1253
+ # _compute_skill_lag haste/slow scaling is FIGHT-085's separate concern and
1254
+ # must stay out of ROM WAIT_STATE sites).
1255
+ _trip_skill = skill_registry.get("trip")
1256
+ trip_beats = int(getattr(_trip_skill, "beats", 0) or 0)
1233
1257
 
1234
1258
  if victim is char:
1235
- skill_registry._apply_wait_state(char, get_pulse_violence() * 2)
1236
- return "You fall flat on your face!"
1259
+ # ROM src/fight.c:2697-2703 (do_trip self-trip): colored TO_CHAR + a room
1260
+ # broadcast ($n PERS-masked, $s possessive). FIGHT-090: the room line +
1261
+ # {5..{x colour were on skill_handlers.trip (FIGHT-039) but not here — merged
1262
+ # in so this path is the complete canonical implementation.
1263
+ skill_registry._apply_wait_state(char, 2 * trip_beats)
1264
+ self_room = getattr(char, "room", None)
1265
+ if self_room is not None:
1266
+ act_to_room(self_room, "{5$n trips over $s own feet!{x", char, exclude=char)
1267
+ return "{5You fall flat on your face!{x"
1237
1268
 
1238
1269
  # FIGHT-071: ROM src/fight.c:2705-2709 — do_trip's charm gate, checked LAST
1239
1270
  # (after flying/position/self), with act("$N is your beloved master.", …).
@@ -1258,6 +1289,13 @@ def do_trip(char: Character, args: str) -> str:
1258
1289
  victim_dex = victim.get_curr_stat(Stat.DEX) or 0
1259
1290
  chance += char_dex - victim_dex * 3 // 2
1260
1291
 
1292
+ # Speed modifier — ROM src/fight.c:2722-2726: attacker OFF_FAST/AFF_HASTE
1293
+ # adds +10, victim OFF_FAST/AFF_HASTE subtracts 20.
1294
+ if (_npc_off_flags(char) & OffFlag.FAST) or char.has_affect(AffectFlag.HASTE):
1295
+ chance += 10
1296
+ if (_npc_off_flags(victim) & OffFlag.FAST) or victim.has_affect(AffectFlag.HASTE):
1297
+ chance -= 20
1298
+
1261
1299
  # Level modifier
1262
1300
  char_level = skill_handlers._coerce_int(getattr(char, "level", 1))
1263
1301
  victim_level = skill_handlers._coerce_int(getattr(victim, "level", 1))
@@ -1265,19 +1303,44 @@ def do_trip(char: Character, args: str) -> str:
1265
1303
 
1266
1304
  # Roll
1267
1305
  if rng_mm.number_percent() < chance:
1268
- # Success
1306
+ # Success — ROM src/fight.c:2735-2745.
1307
+ # FIGHT-088: ROM emits three act() lines before the damage() call. TO_VICT is
1308
+ # pushed to the victim, TO_NOTVICT broadcasts to the room ($M = victim's
1309
+ # objective pronoun), TO_CHAR is this command's return value. Replaces a
1310
+ # single baked f-string with no $N/$M render and no room broadcast.
1311
+ from mud.utils.messaging import send_to_char_buffered as _send
1312
+
1313
+ _send(victim, act_format("{5$n trips you and you go down!{x", recipient=victim, actor=char, arg2=victim))
1314
+ room = getattr(char, "room", None)
1315
+ if room is not None:
1316
+ act_to_room(room, "{5$n trips $N, sending $M to the ground.{x", char, arg2=victim, exclude=victim)
1317
+
1318
+ # DAZE_STATE(victim, 2 * PULSE_VIOLENCE) — the victim is DAZED, not WAIT'd.
1319
+ victim.daze = max(int(getattr(victim, "daze", 0) or 0), 2 * get_pulse_violence())
1320
+ # WAIT_STATE(ch, skill_table[gsn_trip].beats) — raw beats, not PULSE_VIOLENCE.
1321
+ skill_registry._apply_wait_state(char, trip_beats)
1269
1322
  victim.position = Position.RESTING
1270
- skill_registry._apply_wait_state(char, get_pulse_violence())
1271
- skill_registry._apply_wait_state(victim, get_pulse_violence() * 2)
1272
1323
 
1273
- # Damage
1274
- damage_amt = rng_mm.number_range(2, 2 + 2 * victim_size + skill_level // 20)
1275
- apply_damage(char, victim, damage_amt, DamageType.BASH)
1324
+ # Damage — ROM :2744 number_range(2, 2 + 2 * victim->size); no skill-level term.
1325
+ # dt=gsn_trip so the dam_message renders the trip verb (ROM passes gsn_trip).
1326
+ damage_amt = rng_mm.number_range(2, 2 + 2 * victim_size)
1327
+ apply_damage(char, victim, damage_amt, DamageType.BASH, dt="trip")
1276
1328
 
1277
- message = f"You trip {getattr(victim, 'name', 'them')} and they go down!"
1329
+ # ROM src/fight.c:2739 check_improve(ch, gsn_trip, TRUE, 1). FIGHT-090:
1330
+ # do_trip was missing check_improve entirely (it was only on the duplicate
1331
+ # skill_handlers.trip); merged in so trip improvement works via the command.
1332
+ skill_registry._check_improve(char, _trip_skill, "trip", True)
1333
+ message = act_format("{5You trip $N and $N goes down!{x", recipient=char, actor=char, arg2=victim)
1278
1334
  else:
1279
- skill_registry._apply_wait_state(char, get_pulse_violence())
1280
- message = "You try to trip them but miss."
1335
+ # FIGHT-088: ROM :2749 — the failure branch calls damage(ch, victim, 0,
1336
+ # gsn_trip, DAM_BASH, TRUE) BEFORE the wait, delivering the miss dam_message
1337
+ # and starting the fight (set_fighting). apply_damage returns the attacker's
1338
+ # TO_CHAR line unpushed (single-delivery), so returning it is correct.
1339
+ message = apply_damage(char, victim, 0, DamageType.BASH, dt="trip")
1340
+ # ROM :2750 — WAIT_STATE(ch, skill_table[gsn_trip].beats * 2 / 3), after damage.
1341
+ skill_registry._apply_wait_state(char, trip_beats * 2 // 3)
1342
+ # ROM src/fight.c:2752 — check_improve(ch, gsn_trip, FALSE, 1). FIGHT-090.
1343
+ skill_registry._check_improve(char, _trip_skill, "trip", False)
1281
1344
 
1282
1345
  check_killer(char, victim) # mirroring ROM src/fight.c:2753 — unconditional
1283
1346
  return message
@@ -12,6 +12,7 @@ from mud.math.c_compat import c_div
12
12
  from mud.models.constants import AffectFlag, ItemType
13
13
  from mud.utils.act import act_to_room
14
14
  from mud.utils.rng_mm import number_fuzzy
15
+ from mud.world.obj_find import get_obj_carry
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from mud.models.character import Character
@@ -36,8 +37,9 @@ def do_eat(ch: Character, args: str) -> str:
36
37
  if not args:
37
38
  return "Eat what?"
38
39
 
39
- # Find object in inventory
40
- obj = _find_obj_inventory(ch, args)
40
+ # Find object in inventory — ROM src/act_obj.c:1296 uses get_obj_carry, which
41
+ # parses the `N.name` count prefix and matches keywords (EAT-008).
42
+ obj = get_obj_carry(ch, args)
41
43
  if not obj:
42
44
  return "You do not have that item."
43
45
 
@@ -321,32 +323,6 @@ def do_drink(ch: Character, args: str) -> str:
321
323
  return "\n".join(messages)
322
324
 
323
325
 
324
- def _find_obj_inventory(ch: Character, name: str) -> Object | None:
325
- """Find an object in character's inventory by name.
326
-
327
- ROM Reference: src/handler.c get_obj_carry — searches `ch->carrying`.
328
- QuickMUD stores the carrying list on `Character.inventory` (see
329
- `mud/models/character.py:396`).
330
- """
331
- inventory = ch.inventory
332
- if not inventory or not name:
333
- return None
334
-
335
- name_lower = name.lower()
336
- for obj in inventory:
337
- # Check short description
338
- short_descr = getattr(obj, "short_descr", "")
339
- if name_lower in short_descr.lower():
340
- return obj
341
-
342
- # Check name
343
- obj_name = getattr(obj, "name", "")
344
- if name_lower in obj_name.lower():
345
- return obj
346
-
347
- return None
348
-
349
-
350
326
  def _destroy_object(ch: Character, obj: Object) -> None:
351
327
  """Remove an object from character's inventory and destroy it.
352
328
 
mud/commands/doors.py CHANGED
@@ -163,10 +163,13 @@ def do_open(char: Character, args: str) -> str:
163
163
 
164
164
  # Container
165
165
  if item_type == ItemType.CONTAINER:
166
- if not (values[1] & ContainerFlag.CLOSEABLE):
167
- return "You can't do that."
166
+ # MOVE-008: ROM do_open (src/act_move.c) checks CONT_CLOSED before
167
+ # CONT_CLOSEABLE, so an already-open non-closeable container reports
168
+ # "It's already open.", not "You can't do that."
168
169
  if not (values[1] & ContainerFlag.CLOSED):
169
170
  return "It's already open."
171
+ if not (values[1] & ContainerFlag.CLOSEABLE):
172
+ return "You can't do that."
170
173
  if values[1] & ContainerFlag.LOCKED:
171
174
  return "It's locked."
172
175
 
@@ -258,10 +261,13 @@ def do_close(char: Character, args: str) -> str:
258
261
 
259
262
  # Container
260
263
  if item_type == ItemType.CONTAINER:
261
- if not (values[1] & ContainerFlag.CLOSEABLE):
262
- return "You can't do that."
264
+ # MOVE-008: ROM do_close (src/act_move.c) checks CONT_CLOSED before
265
+ # CONT_CLOSEABLE, so an already-closed non-closeable container reports
266
+ # "It's already closed.", not "You can't do that."
263
267
  if values[1] & ContainerFlag.CLOSED:
264
268
  return "It's already closed."
269
+ if not (values[1] & ContainerFlag.CLOSEABLE):
270
+ return "You can't do that."
265
271
 
266
272
  obj.value[1] = values[1] | ContainerFlag.CLOSED
267
273
  obj_name = getattr(obj, "short_descr", None) or getattr(obj, "name", "it")
@@ -655,9 +661,13 @@ def do_pick(char: Character, args: str) -> str:
655
661
  exit_info = getattr(pexit, "exit_info", 0)
656
662
  key_vnum = getattr(pexit, "key", 0)
657
663
 
658
- # Immortal check (ROM C lines 958, 963, 973)
659
- trust = getattr(char, "trust", 0)
660
- is_immortal = trust >= 51 # LEVEL_HERO threshold
664
+ # Immortal check (ROM C lines 958, 963, 973): !IS_IMMORTAL(ch), where
665
+ # IS_IMMORTAL = get_trust(ch) >= LEVEL_IMMORTAL (=52, src/merc.h:149,2091)
666
+ # and get_trust falls back to ch->level when ch->trust is unset. The prior
667
+ # `trust >= 51` was wrong twice: 51 is LEVEL_HERO (a mortal), and reading raw
668
+ # `char.trust` skipped the level fallback (a level-52 immortal with trust 0
669
+ # was wrongly refused). Character.is_immortal() encodes both correctly.
670
+ is_immortal = char.is_immortal()
661
671
 
662
672
  if not (exit_info & EX_CLOSED) and not is_immortal:
663
673
  return "It's not closed."
mud/commands/equipment.py CHANGED
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
11
11
  from mud.handler import equip_char, unequip_char
12
12
  from mud.models.constants import ExtraFlag, ItemType, Position, WearFlag, WearLocation
13
13
  from mud.utils.act import act_to_room
14
+ from mud.world.obj_find import get_obj_carry
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  from mud.models.character import Character
@@ -155,8 +156,10 @@ def do_wear(ch: Character, args: str) -> str:
155
156
  if args.lower() == "all":
156
157
  return _wear_all(ch)
157
158
 
158
- # Find object in inventory
159
- obj = _find_obj_inventory(ch, args)
159
+ # Find object in inventory — ROM src/act_obj.c:1726 uses get_obj_carry, which
160
+ # parses the `N.name` count prefix and matches keywords (WEAR-010). wield/hold
161
+ # delegate to do_wear, so this covers all three.
162
+ obj = get_obj_carry(ch, args)
160
163
  if not obj:
161
164
  return "You do not have that item."
162
165
 
@@ -660,24 +663,3 @@ def _get_wear_location(obj: Object, wear_flags: int, ch: Character | None = None
660
663
  return WearLocation.FLOAT
661
664
 
662
665
  return None
663
-
664
-
665
- def _find_obj_inventory(ch: Character, name: str) -> Object | None:
666
- """Find an object in character's inventory by name."""
667
- inventory = getattr(ch, "inventory", [])
668
- if not inventory or not name:
669
- return None
670
-
671
- name_lower = name.lower()
672
- for obj in inventory:
673
- # Check short description
674
- short_descr = getattr(obj, "short_descr", "")
675
- if name_lower in short_descr.lower():
676
- return obj
677
-
678
- # Check name
679
- obj_name = getattr(obj, "name", "")
680
- if name_lower in obj_name.lower():
681
- return obj
682
-
683
- return None