rom24-quickmud-python 1.2.2__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 (135) hide show
  1. mud/__init__.py +0 -0
  2. mud/__main__.py +40 -0
  3. mud/account/__init__.py +20 -0
  4. mud/account/account_manager.py +62 -0
  5. mud/account/account_service.py +80 -0
  6. mud/advancement.py +62 -0
  7. mud/affects/saves.py +123 -0
  8. mud/agent/__init__.py +0 -0
  9. mud/agent/agent_protocol.py +19 -0
  10. mud/agent/character_agent.py +61 -0
  11. mud/combat/__init__.py +3 -0
  12. mud/combat/engine.py +189 -0
  13. mud/commands/__init__.py +3 -0
  14. mud/commands/admin_commands.py +77 -0
  15. mud/commands/advancement.py +36 -0
  16. mud/commands/alias_cmds.py +44 -0
  17. mud/commands/build.py +18 -0
  18. mud/commands/combat.py +16 -0
  19. mud/commands/communication.py +55 -0
  20. mud/commands/decorators.py +11 -0
  21. mud/commands/dispatcher.py +206 -0
  22. mud/commands/healer.py +81 -0
  23. mud/commands/help.py +14 -0
  24. mud/commands/imc.py +19 -0
  25. mud/commands/inspection.py +113 -0
  26. mud/commands/inventory.py +42 -0
  27. mud/commands/movement.py +71 -0
  28. mud/commands/notes.py +44 -0
  29. mud/commands/shop.py +138 -0
  30. mud/commands/socials.py +34 -0
  31. mud/config.py +59 -0
  32. mud/db/__init__.py +0 -0
  33. mud/db/init.py +27 -0
  34. mud/db/migrate_from_files.py +87 -0
  35. mud/db/migrations.py +7 -0
  36. mud/db/models.py +98 -0
  37. mud/db/seed.py +28 -0
  38. mud/db/session.py +11 -0
  39. mud/devtools/__init__.py +0 -0
  40. mud/devtools/agent_demo.py +19 -0
  41. mud/entrypoint.py +34 -0
  42. mud/game_loop.py +117 -0
  43. mud/imc/__init__.py +17 -0
  44. mud/imc/protocol.py +32 -0
  45. mud/loaders/__init__.py +27 -0
  46. mud/loaders/area_loader.py +73 -0
  47. mud/loaders/base_loader.py +38 -0
  48. mud/loaders/help_loader.py +17 -0
  49. mud/loaders/json_area_loader.py +203 -0
  50. mud/loaders/json_loader.py +285 -0
  51. mud/loaders/mob_loader.py +104 -0
  52. mud/loaders/obj_loader.py +76 -0
  53. mud/loaders/reset_loader.py +29 -0
  54. mud/loaders/room_loader.py +63 -0
  55. mud/loaders/shop_loader.py +41 -0
  56. mud/loaders/social_loader.py +16 -0
  57. mud/loaders/specials_loader.py +63 -0
  58. mud/logging/__init__.py +0 -0
  59. mud/logging/admin.py +40 -0
  60. mud/logging/agent_trace.py +9 -0
  61. mud/math/c_compat.py +27 -0
  62. mud/mobprog.py +72 -0
  63. mud/models/__init__.py +106 -0
  64. mud/models/area.py +33 -0
  65. mud/models/area_json.py +27 -0
  66. mud/models/board.py +49 -0
  67. mud/models/board_json.py +16 -0
  68. mud/models/character.py +195 -0
  69. mud/models/character_json.py +46 -0
  70. mud/models/constants.py +423 -0
  71. mud/models/conversion.py +45 -0
  72. mud/models/help.py +28 -0
  73. mud/models/help_json.py +14 -0
  74. mud/models/json_io.py +64 -0
  75. mud/models/mob.py +82 -0
  76. mud/models/note.py +29 -0
  77. mud/models/note_json.py +16 -0
  78. mud/models/obj.py +82 -0
  79. mud/models/object.py +28 -0
  80. mud/models/object_json.py +40 -0
  81. mud/models/player_json.py +29 -0
  82. mud/models/room.py +86 -0
  83. mud/models/room_json.py +46 -0
  84. mud/models/shop.py +21 -0
  85. mud/models/shop_json.py +17 -0
  86. mud/models/skill.py +25 -0
  87. mud/models/skill_json.py +20 -0
  88. mud/models/social.py +78 -0
  89. mud/models/social_json.py +20 -0
  90. mud/net/__init__.py +9 -0
  91. mud/net/ansi.py +27 -0
  92. mud/net/connection.py +174 -0
  93. mud/net/protocol.py +57 -0
  94. mud/net/session.py +21 -0
  95. mud/net/telnet_server.py +31 -0
  96. mud/network/__init__.py +0 -0
  97. mud/network/websocket_server.py +83 -0
  98. mud/network/websocket_session.py +21 -0
  99. mud/notes.py +45 -0
  100. mud/persistence.py +185 -0
  101. mud/registry.py +5 -0
  102. mud/scripts/convert_are_to_json.py +162 -0
  103. mud/scripts/convert_help_are_to_json.py +92 -0
  104. mud/scripts/convert_player_to_json.py +112 -0
  105. mud/scripts/convert_shops_to_json.py +64 -0
  106. mud/scripts/convert_skills_to_json.py +166 -0
  107. mud/scripts/convert_social_are_to_json.py +92 -0
  108. mud/scripts/load_test_data.py +17 -0
  109. mud/security/__init__.py +0 -0
  110. mud/security/bans.py +112 -0
  111. mud/security/hash_utils.py +20 -0
  112. mud/server.py +8 -0
  113. mud/skills/__init__.py +3 -0
  114. mud/skills/handlers.py +795 -0
  115. mud/skills/registry.py +97 -0
  116. mud/spawning/__init__.py +1 -0
  117. mud/spawning/mob_spawner.py +13 -0
  118. mud/spawning/obj_spawner.py +18 -0
  119. mud/spawning/reset_handler.py +222 -0
  120. mud/spawning/templates.py +63 -0
  121. mud/spec_funs.py +57 -0
  122. mud/time.py +48 -0
  123. mud/utils/rng_mm.py +123 -0
  124. mud/wiznet.py +74 -0
  125. mud/world/__init__.py +11 -0
  126. mud/world/linking.py +31 -0
  127. mud/world/look.py +29 -0
  128. mud/world/movement.py +135 -0
  129. mud/world/world_state.py +179 -0
  130. rom24_quickmud_python-1.2.2.dist-info/METADATA +236 -0
  131. rom24_quickmud_python-1.2.2.dist-info/RECORD +135 -0
  132. rom24_quickmud_python-1.2.2.dist-info/WHEEL +5 -0
  133. rom24_quickmud_python-1.2.2.dist-info/entry_points.txt +2 -0
  134. rom24_quickmud_python-1.2.2.dist-info/licenses/LICENSE +21 -0
  135. rom24_quickmud_python-1.2.2.dist-info/top_level.txt +1 -0
mud/__init__.py ADDED
File without changes
mud/__main__.py ADDED
@@ -0,0 +1,40 @@
1
+ import asyncio
2
+ import typer
3
+ from mud.server import run_game_loop
4
+ from mud.db.migrations import run_migrations
5
+ from mud.net.telnet_server import start_server as start_telnet
6
+ from mud.network.websocket_server import run as start_websocket
7
+
8
+ cli = typer.Typer()
9
+
10
+ @cli.command()
11
+ def runserver():
12
+ """Start the main game server."""
13
+ run_game_loop()
14
+
15
+
16
+ @cli.command()
17
+ def migrate():
18
+ """Run database migrations."""
19
+ run_migrations()
20
+
21
+
22
+ @cli.command()
23
+ def loadtestuser():
24
+ """Load a default test account and character."""
25
+ from mud.scripts.load_test_data import load_test_user
26
+ load_test_user()
27
+
28
+
29
+ @cli.command()
30
+ def socketserver(host: str = "0.0.0.0", port: int = 5000):
31
+ """Start the telnet server."""
32
+ asyncio.run(start_telnet(host=host, port=port))
33
+
34
+ @cli.command()
35
+ def websocketserver(host: str = "0.0.0.0", port: int = 8000):
36
+ """Start the websocket server."""
37
+ start_websocket(host=host, port=port)
38
+
39
+ if __name__ == "__main__":
40
+ cli()
@@ -0,0 +1,20 @@
1
+ """Account management utilities."""
2
+
3
+ from .account_manager import load_character, save_character
4
+ from .account_service import (
5
+ create_account,
6
+ login,
7
+ login_with_host,
8
+ list_characters,
9
+ create_character,
10
+ )
11
+
12
+ __all__ = [
13
+ "load_character",
14
+ "save_character",
15
+ "create_account",
16
+ "login",
17
+ "login_with_host",
18
+ "list_characters",
19
+ "create_character",
20
+ ]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from mud.db.session import SessionLocal
6
+ from mud.db.models import Character as DBCharacter, PlayerAccount
7
+ from mud.models.character import Character, from_orm
8
+ from mud.models.conversion import (
9
+ load_objects_for_character,
10
+ save_objects_for_character,
11
+ )
12
+
13
+
14
+ def load_character(username: str, char_name: str) -> Optional[Character]:
15
+ session = None
16
+ try:
17
+ session = SessionLocal()
18
+ db_char = (
19
+ session.query(DBCharacter)
20
+ .join(PlayerAccount)
21
+ .filter(
22
+ DBCharacter.name == char_name,
23
+ PlayerAccount.username == username,
24
+ )
25
+ .first()
26
+ )
27
+ char = from_orm(db_char) if db_char else None
28
+ if char and db_char:
29
+ db_char.player # load relationship
30
+ char.inventory, char.equipment = load_objects_for_character(
31
+ db_char
32
+ )
33
+ return char
34
+ except Exception as e:
35
+ print(f"[ERROR] Failed to load character {char_name}: {e}")
36
+ return None
37
+ finally:
38
+ if session:
39
+ session.close()
40
+
41
+
42
+ def save_character(character: Character) -> None:
43
+ session = None
44
+ try:
45
+ session = SessionLocal()
46
+ db_char = (
47
+ session.query(DBCharacter)
48
+ .filter_by(name=character.name)
49
+ .first()
50
+ )
51
+ if db_char:
52
+ db_char.level = character.level
53
+ db_char.hp = character.hit
54
+ if getattr(character, "room", None):
55
+ db_char.room_vnum = character.room.vnum
56
+ save_objects_for_character(session, character, db_char)
57
+ session.commit()
58
+ except Exception as e:
59
+ print(f"[ERROR] Failed to save character {character.name}: {e}")
60
+ finally:
61
+ if session:
62
+ session.close()
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, List
4
+
5
+ from mud.db.session import SessionLocal
6
+ from mud.db.models import PlayerAccount, Character
7
+ from mud.security.hash_utils import hash_password, verify_password
8
+ from mud.security import bans
9
+
10
+
11
+ def create_account(username: str, raw_password: str) -> bool:
12
+ """Create a new PlayerAccount if username is available."""
13
+ session = SessionLocal()
14
+ if session.query(PlayerAccount).filter_by(username=username).first():
15
+ session.close()
16
+ return False
17
+ account = PlayerAccount(
18
+ username=username,
19
+ password_hash=hash_password(raw_password),
20
+ )
21
+ session.add(account)
22
+ session.commit()
23
+ session.close()
24
+ return True
25
+
26
+
27
+ def login(username: str, raw_password: str) -> Optional[PlayerAccount]:
28
+ """Return PlayerAccount if credentials match."""
29
+ # Enforce account-name bans irrespective of host.
30
+ if bans.is_account_banned(username):
31
+ return None
32
+ session = SessionLocal()
33
+ account = session.query(PlayerAccount).filter_by(username=username).first()
34
+ if account and verify_password(raw_password, account.password_hash):
35
+ # pre-load characters before detaching
36
+ account.characters # type: ignore[unused-any]
37
+ session.expunge(account)
38
+ session.close()
39
+ return account
40
+ session.close()
41
+ return None
42
+
43
+
44
+ def login_with_host(
45
+ username: str, raw_password: str, host: str | None
46
+ ) -> Optional[PlayerAccount]:
47
+ """Login that also enforces site bans.
48
+
49
+ This wrapper checks both account and host bans and only then delegates to
50
+ the standard login function.
51
+ """
52
+ if bans.is_host_banned(host):
53
+ return None
54
+ return login(username, raw_password)
55
+
56
+
57
+ def list_characters(account: PlayerAccount) -> List[str]:
58
+ """Return list of character names for this account."""
59
+ return [char.name for char in getattr(account, "characters", [])]
60
+
61
+
62
+ def create_character(
63
+ account: PlayerAccount, name: str, starting_room_vnum: int = 3001
64
+ ) -> bool:
65
+ """Create a new character for the account."""
66
+ session = SessionLocal()
67
+ if session.query(Character).filter_by(name=name).first():
68
+ session.close()
69
+ return False
70
+ new_char = Character(
71
+ name=name,
72
+ level=1,
73
+ hp=100,
74
+ room_vnum=starting_room_vnum,
75
+ player_id=account.id,
76
+ )
77
+ session.add(new_char)
78
+ session.commit()
79
+ session.close()
80
+ return True
mud/advancement.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from mud.models.character import Character
4
+
5
+ BASE_XP_PER_LEVEL = 1000
6
+
7
+ # Multipliers roughly mirroring ROM class/race adjustments.
8
+ # Index by `ch_class` or `race` id; default to 1.0 if not found.
9
+ CLASS_XP_MOD = {
10
+ 0: 1.0, # mage
11
+ 1: 1.0, # cleric
12
+ 2: 1.1, # thief
13
+ 3: 1.2, # warrior
14
+ }
15
+
16
+ RACE_XP_MOD = {
17
+ 0: 1.0, # human
18
+ 1: 1.1, # elf
19
+ 2: 0.9, # dwarf
20
+ }
21
+
22
+ # Per-class stat gains applied on level-up: (hp, mana, move)
23
+ LEVEL_BONUS = {
24
+ 0: (8, 6, 4), # mage
25
+ 1: (6, 8, 4), # cleric
26
+ 2: (7, 6, 5), # thief
27
+ 3: (10, 4, 6), # warrior
28
+ }
29
+
30
+ PRACTICES_PER_LEVEL = 2
31
+ TRAINS_PER_LEVEL = 1
32
+
33
+ def exp_per_level(char: Character) -> int:
34
+ """Base experience required for a single level."""
35
+ class_mod = CLASS_XP_MOD.get(char.ch_class, 1.0)
36
+ race_mod = RACE_XP_MOD.get(char.race, 1.0)
37
+ return int(BASE_XP_PER_LEVEL * class_mod * race_mod)
38
+
39
+
40
+ def advance_level(char: Character) -> None:
41
+ """Increase hit points, mana, move, practices, and trains."""
42
+ hp, mana, move = LEVEL_BONUS.get(char.ch_class, (8, 6, 5))
43
+ char.max_hit += hp
44
+ char.max_mana += mana
45
+ char.max_move += move
46
+ char.practice += PRACTICES_PER_LEVEL
47
+ char.train += TRAINS_PER_LEVEL
48
+
49
+ def gain_exp(char: Character, amount: int) -> None:
50
+ """Grant experience and handle level ups.
51
+
52
+ Experience required to reach *n* is ``exp_per_level(char) * n``.
53
+ The character's ``exp`` tracks total lifetime experience.
54
+ """
55
+ if amount <= 0:
56
+ return
57
+ char.exp += amount
58
+ # Level up while total exp meets threshold for next level.
59
+ while char.exp >= exp_per_level(char) * (char.level + 1):
60
+ char.level += 1
61
+ advance_level(char)
62
+
mud/affects/saves.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from mud.math.c_compat import c_div, urange
4
+ from mud.models.character import Character
5
+ from mud.models.constants import AffectFlag, DamageType, DefenseBit
6
+ from mud.utils import rng_mm
7
+
8
+
9
+ # Minimal fMana mapping from ROM const.c order: mage, cleric → True; thief, warrior → False
10
+ FMANA_BY_CLASS = {
11
+ 0: True, # mage
12
+ 1: True, # cleric
13
+ 2: False, # thief
14
+ 3: False, # warrior
15
+ }
16
+
17
+
18
+ def _check_immune(victim: Character, dam_type: int) -> int:
19
+ """ROM-compatible check_immune.
20
+
21
+ Returns one of: IS_NORMAL=0, IS_IMMUNE=1, IS_RESISTANT=2, IS_VULNERABLE=3.
22
+ Mirrors src/handler.c:check_immune with globals (WEAPON/MAGIC) and per-type bits.
23
+ """
24
+ IS_NORMAL = 0
25
+ IS_IMMUNE = 1
26
+ IS_RESISTANT = 2
27
+ IS_VULNERABLE = 3
28
+
29
+ if dam_type == DamageType.NONE:
30
+ return -1
31
+
32
+ # Default from global WEAPON/MAGIC flags
33
+ if dam_type <= DamageType.SLASH:
34
+ if victim.imm_flags & DefenseBit.WEAPON:
35
+ default = IS_IMMUNE
36
+ elif victim.res_flags & DefenseBit.WEAPON:
37
+ default = IS_RESISTANT
38
+ elif victim.vuln_flags & DefenseBit.WEAPON:
39
+ default = IS_VULNERABLE
40
+ else:
41
+ default = IS_NORMAL
42
+ else:
43
+ if victim.imm_flags & DefenseBit.MAGIC:
44
+ default = IS_IMMUNE
45
+ elif victim.res_flags & DefenseBit.MAGIC:
46
+ default = IS_RESISTANT
47
+ elif victim.vuln_flags & DefenseBit.MAGIC:
48
+ default = IS_VULNERABLE
49
+ else:
50
+ default = IS_NORMAL
51
+
52
+ # Map dam_type to specific IMM_* bit
53
+ bit = None
54
+ dt = DamageType(dam_type)
55
+ mapping = {
56
+ DamageType.BASH: DefenseBit.BASH,
57
+ DamageType.PIERCE: DefenseBit.PIERCE,
58
+ DamageType.SLASH: DefenseBit.SLASH,
59
+ DamageType.FIRE: DefenseBit.FIRE,
60
+ DamageType.COLD: DefenseBit.COLD,
61
+ DamageType.LIGHTNING: DefenseBit.LIGHTNING,
62
+ DamageType.ACID: DefenseBit.ACID,
63
+ DamageType.POISON: DefenseBit.POISON,
64
+ DamageType.NEGATIVE: DefenseBit.NEGATIVE,
65
+ DamageType.HOLY: DefenseBit.HOLY,
66
+ DamageType.ENERGY: DefenseBit.ENERGY,
67
+ DamageType.MENTAL: DefenseBit.MENTAL,
68
+ DamageType.DISEASE: DefenseBit.DISEASE,
69
+ DamageType.DROWNING: DefenseBit.DROWNING,
70
+ DamageType.LIGHT: DefenseBit.LIGHT,
71
+ DamageType.CHARM: DefenseBit.CHARM,
72
+ DamageType.SOUND: DefenseBit.SOUND,
73
+ }
74
+ bit = mapping.get(dt)
75
+ if bit is None:
76
+ return default
77
+
78
+ immune = -1
79
+ if victim.imm_flags & bit:
80
+ immune = IS_IMMUNE
81
+ elif (victim.res_flags & bit) and immune != IS_IMMUNE:
82
+ immune = IS_RESISTANT
83
+ elif victim.vuln_flags & bit:
84
+ if immune == IS_IMMUNE:
85
+ immune = IS_RESISTANT
86
+ elif immune == IS_RESISTANT:
87
+ immune = IS_NORMAL
88
+ else:
89
+ immune = IS_VULNERABLE
90
+
91
+ return default if immune == -1 else immune
92
+
93
+
94
+ def saves_spell(level: int, victim: Character, dam_type: int) -> bool:
95
+ """Compute ROM-like saving throw outcome.
96
+
97
+ Mirrors src/magic.c:saves_spell() logic:
98
+ - base: 50 + (victim.level - level) * 5 - victim.saving_throw * 2
99
+ - berserk: + victim.level/2 (C integer division)
100
+ - immunity/resistance/vulnerability adjustments (stubbed to normal for now)
101
+ - player classes with fMana: save = 9*save/10 (C division)
102
+ - clamp 5..95, succeed if number_percent() < save
103
+ """
104
+ save = 50 + (victim.level - level) * 5 - victim.saving_throw * 2
105
+
106
+ if victim.has_affect(AffectFlag.BERSERK):
107
+ save += c_div(victim.level, 2)
108
+
109
+ riv = _check_immune(victim, dam_type)
110
+ # IS_IMMUNE(1) → auto success; IS_RESISTANT(2) → +2; IS_VULNERABLE(3) → -2
111
+ if riv == 1:
112
+ return True
113
+ if riv == 2:
114
+ save += 2
115
+ elif riv == 3:
116
+ save -= 2
117
+
118
+ # Not NPC → apply fMana reduction if class gains mana
119
+ if FMANA_BY_CLASS.get(victim.ch_class, False):
120
+ save = c_div(9 * save, 10)
121
+
122
+ save = urange(5, save, 95)
123
+ return rng_mm.number_percent() < save
mud/agent/__init__.py ADDED
File without changes
@@ -0,0 +1,19 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Dict, List
3
+
4
+
5
+ class AgentInterface(ABC):
6
+ @abstractmethod
7
+ def get_observation(self) -> Dict:
8
+ """Return structured game state view."""
9
+ raise NotImplementedError
10
+
11
+ @abstractmethod
12
+ def get_available_actions(self) -> List[str]:
13
+ """Return a list of valid actions the agent can choose from."""
14
+ raise NotImplementedError
15
+
16
+ @abstractmethod
17
+ def perform_action(self, action: str, args: List[str]) -> str:
18
+ """Execute an action in-game. Returns textual feedback."""
19
+ raise NotImplementedError
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+ from typing import List
3
+
4
+ from mud.models.character import Character
5
+ from mud.agent.agent_protocol import AgentInterface
6
+ from mud.world.movement import move_character
7
+ from mud.commands.communication import do_say
8
+ from mud.commands.inventory import do_get, do_drop
9
+
10
+
11
+ class CharacterAgentAdapter(AgentInterface):
12
+ def __init__(self, character: Character):
13
+ self.character = character
14
+
15
+ def get_observation(self):
16
+ room = self.character.room
17
+ return {
18
+ "name": self.character.name,
19
+ "room": {
20
+ "vnum": room.vnum if room else None,
21
+ "name": getattr(room, "name", None),
22
+ "description": getattr(room, "description", None),
23
+ "npcs": [getattr(npc, "name", None) for npc in getattr(room, "people", []) if npc is not self.character],
24
+ "players": [p.name for p in getattr(room, "people", []) if getattr(p, "name", None) and hasattr(p, "messages") and p is not self.character],
25
+ "exits": [i for i, ex in enumerate(getattr(room, "exits", [])) if ex],
26
+ } if room else None,
27
+ "inventory": [obj.short_descr or obj.name for obj in self.character.inventory],
28
+ "equipment": {slot: obj.short_descr or obj.name for slot, obj in self.character.equipment.items()},
29
+ "hp": getattr(self.character, "hit", 0),
30
+ "level": getattr(self.character, "level", 0),
31
+ }
32
+
33
+ def get_available_actions(self) -> List[str]:
34
+ return ["move", "say", "pickup", "drop", "equip", "attack"]
35
+
36
+ def perform_action(self, action: str, args: List[str]) -> str:
37
+ try:
38
+ if action == "move":
39
+ direction = args[0]
40
+ return move_character(self.character, direction)
41
+ elif action == "say":
42
+ return do_say(self.character, " ".join(args))
43
+ elif action == "pickup":
44
+ return do_get(self.character, args[0])
45
+ elif action == "drop":
46
+ return do_drop(self.character, args[0])
47
+ elif action == "equip":
48
+ item_name = args[0]
49
+ slot = args[1] if len(args) > 1 else "wield"
50
+ for obj in list(self.character.inventory):
51
+ obj_name = (obj.short_descr or obj.name or "").lower()
52
+ if item_name.lower() in obj_name:
53
+ self.character.equip_object(obj, slot)
54
+ return f"Equipped {obj_name} on {slot}"
55
+ return "Item not found."
56
+ elif action == "attack":
57
+ return "Attack not implemented"
58
+ else:
59
+ return f"Unknown action: {action}"
60
+ except Exception as e:
61
+ return f"⚠️ Error: {str(e)}"
mud/combat/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Combat engine utilities."""
2
+ from .engine import attack_round
3
+ __all__ = ["attack_round"]
mud/combat/engine.py ADDED
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ from mud.models.character import Character
4
+ from mud.models.constants import (
5
+ Position,
6
+ DamageType,
7
+ AC_PIERCE,
8
+ AC_BASH,
9
+ AC_SLASH,
10
+ AC_EXOTIC,
11
+ )
12
+ from mud.utils import rng_mm
13
+ from mud.math.c_compat import c_div
14
+ from mud.affects.saves import _check_immune as _riv_check
15
+ from mud.math.c_compat import urange
16
+ from mud.models.constants import AffectFlag
17
+ from mud.config import COMBAT_USE_THAC0
18
+
19
+
20
+ def attack_round(attacker: Character, victim: Character) -> str:
21
+ """Resolve a single attack round.
22
+
23
+ The attacker attempts to hit the victim. Hit chance is derived from a
24
+ base 50% modified by the attacker's ``hitroll``. Successful hits apply
25
+ ``damroll`` damage. Living combatants are placed into FIGHTING position.
26
+ If the victim dies, they are removed from the room and their position set
27
+ to ``DEAD``.
28
+ """
29
+
30
+ attacker.position = Position.FIGHTING
31
+ # Capture victim's pre-attack position for ROM-like modifiers
32
+ _victim_pos_before = victim.position
33
+ victim.position = Position.FIGHTING
34
+
35
+ dam_type = attacker.dam_type or int(DamageType.BASH)
36
+ ac_idx = ac_index_for_dam_type(dam_type)
37
+ victim_ac = 0
38
+ if hasattr(victim, "armor") and 0 <= ac_idx < len(victim.armor):
39
+ victim_ac = victim.armor[ac_idx]
40
+ # Visibility and position modifiers (ROM-inspired)
41
+ if getattr(victim, "has_affect", None) and victim.has_affect(AffectFlag.INVISIBLE):
42
+ victim_ac -= 4
43
+ if _victim_pos_before < Position.FIGHTING:
44
+ victim_ac += 4
45
+ if _victim_pos_before < Position.RESTING:
46
+ victim_ac += 6
47
+
48
+ if COMBAT_USE_THAC0:
49
+ # ROM diceroll using number_bits(5) until < 20
50
+ while True:
51
+ diceroll = rng_mm.number_bits(5)
52
+ if diceroll < 20:
53
+ break
54
+ # Compute class-based thac0 with hitroll/skill contributions
55
+ th = compute_thac0(attacker.level, attacker.ch_class, hitroll=attacker.hitroll, skill=100)
56
+ vac = c_div(victim_ac, 10)
57
+ # Miss if nat 0 or (not 19 and diceroll < thac0 - victim_ac)
58
+ if diceroll == 0 or (diceroll != 19 and diceroll < (th - vac)):
59
+ return f"You miss {victim.name}."
60
+ else:
61
+ # Percent model kept for parity stability outside feature flag
62
+ to_hit = 50 + attacker.hitroll
63
+ # Use C-style division for negative AC to match ROM semantics
64
+ to_hit += c_div(victim_ac, 2)
65
+ to_hit = urange(5, to_hit, 100)
66
+ if rng_mm.number_percent() > to_hit:
67
+ return f"You miss {victim.name}."
68
+
69
+ # Defense checks in ROM order: shield block → parry → dodge.
70
+ if check_shield_block(attacker, victim):
71
+ return f"{victim.name} blocks your attack with a shield."
72
+ if check_parry(attacker, victim):
73
+ return f"{victim.name} parries your attack."
74
+ if check_dodge(attacker, victim):
75
+ return f"{victim.name} dodges your attack."
76
+
77
+ damage = max(1, attacker.damroll)
78
+ # Apply RIV (IMMUNE/RESIST/VULN) scaling before any side-effects.
79
+ dam_type = attacker.dam_type or int(DamageType.BASH)
80
+ riv = _riv_check(victim, dam_type)
81
+ if riv == 1: # IS_IMMUNE
82
+ damage = 0
83
+ elif riv == 2: # IS_RESISTANT: dam -= dam/3 (ROM)
84
+ damage = damage - c_div(damage, 3)
85
+ elif riv == 3: # IS_VULNERABLE: dam += dam/2 (ROM)
86
+ damage = damage + c_div(damage, 2)
87
+
88
+ # Invoke any on-hit effects with scaled damage (can be monkeypatched in tests).
89
+ on_hit_effects(attacker, victim, damage)
90
+ victim.hit -= damage
91
+ if victim.hit <= 0:
92
+ victim.hit = 0
93
+ victim.position = Position.DEAD
94
+ attacker.position = Position.STANDING
95
+ if getattr(victim, "room", None):
96
+ victim.room.broadcast(f"{victim.name} is DEAD!!!", exclude=victim)
97
+ victim.room.remove_character(victim)
98
+ return f"You kill {victim.name}."
99
+ return f"You hit {victim.name} for {damage} damage."
100
+
101
+
102
+ def on_hit_effects(attacker: Character, victim: Character, damage: int) -> None: # pragma: no cover - default no-op
103
+ """Hook for on-hit side-effects; receives RIV-scaled damage."""
104
+ return None
105
+
106
+
107
+ # --- Defense checks (override in tests as needed) ---
108
+ def check_shield_block(attacker: Character, victim: Character) -> bool:
109
+ """Basic shield block chance using victim.shield_block_chance (percent).
110
+
111
+ Defaults to 0 if not set. Uses rng_mm.number_percent() ≤ chance.
112
+ """
113
+ chance = getattr(victim, "shield_block_chance", 0) or 0
114
+ if chance <= 0:
115
+ return False
116
+ return rng_mm.number_percent() <= chance
117
+
118
+
119
+ def check_parry(attacker: Character, victim: Character) -> bool:
120
+ """Basic parry chance using victim.parry_chance (percent)."""
121
+ chance = getattr(victim, "parry_chance", 0) or 0
122
+ if chance <= 0:
123
+ return False
124
+ return rng_mm.number_percent() <= chance
125
+
126
+
127
+ def check_dodge(attacker: Character, victim: Character) -> bool:
128
+ """Basic dodge chance using victim.dodge_chance (percent)."""
129
+ chance = getattr(victim, "dodge_chance", 0) or 0
130
+ if chance <= 0:
131
+ return False
132
+ return rng_mm.number_percent() <= chance
133
+
134
+
135
+ # --- AC mapping helpers ---
136
+ def ac_index_for_dam_type(dam_type: int) -> int:
137
+ """Map a damage type to the correct AC index.
138
+
139
+ ROM maps: PIERCE→AC_PIERCE, BASH→AC_BASH, SLASH→AC_SLASH, everything else→AC_EXOTIC.
140
+ Unarmed (NONE) is treated as BASH.
141
+ """
142
+ dt = DamageType(dam_type) if not isinstance(dam_type, DamageType) else dam_type
143
+ if dt == DamageType.PIERCE:
144
+ return AC_PIERCE
145
+ if dt == DamageType.BASH or dt == DamageType.NONE:
146
+ return AC_BASH
147
+ if dt == DamageType.SLASH:
148
+ return AC_SLASH
149
+ return AC_EXOTIC
150
+
151
+
152
+ def is_better_ac(ac_a: int, ac_b: int) -> bool:
153
+ """Return True if ac_a is better protection than ac_b (more negative)."""
154
+ return ac_a < ac_b
155
+
156
+
157
+ # --- THAC0 interpolation (ROM-inspired) ---
158
+ # Class ids align with FMANA mapping used elsewhere: 0:mage, 1:cleric, 2:thief, 3:warrior
159
+ THAC0_TABLE: dict[int, tuple[int, int]] = {
160
+ 0: (20, 6), # mage
161
+ 1: (20, 2), # cleric
162
+ 2: (20, -4), # thief
163
+ 3: (20, -10), # warrior
164
+ }
165
+
166
+
167
+ def interpolate(level: int, v00: int, v32: int) -> int:
168
+ """ROM-like integer interpolate between level 0 and 32 using C division."""
169
+ return v00 + c_div((v32 - v00) * level, 32)
170
+
171
+
172
+ def compute_thac0(level: int, ch_class: int, *, hitroll: int = 0, skill: int = 100) -> int:
173
+ """Compute THAC0 following ROM fight.c adjustments.
174
+
175
+ - interpolate(level, thac0_00, thac0_32)
176
+ - if thac0 < 0: thac0 = thac0 / 2 (C div)
177
+ - if thac0 < -5: thac0 = -5 + (thac0 + 5)/2 (C div)
178
+ - thac0 -= hitroll * skill / 100
179
+ - thac0 += 5 * (100 - skill) / 100
180
+ """
181
+ t00, t32 = THAC0_TABLE.get(ch_class, (20, 6))
182
+ th = interpolate(level, t00, t32)
183
+ if th < 0:
184
+ th = c_div(th, 2)
185
+ if th < -5:
186
+ th = -5 + c_div(th + 5, 2)
187
+ th -= c_div(hitroll * skill, 100)
188
+ th += c_div(5 * (100 - skill), 100)
189
+ return th
@@ -0,0 +1,3 @@
1
+ from .dispatcher import process_command, run_test_session
2
+
3
+ __all__ = ["process_command", "run_test_session"]