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.
- mud/__init__.py +0 -0
- mud/__main__.py +40 -0
- mud/account/__init__.py +20 -0
- mud/account/account_manager.py +62 -0
- mud/account/account_service.py +80 -0
- mud/advancement.py +62 -0
- mud/affects/saves.py +123 -0
- mud/agent/__init__.py +0 -0
- mud/agent/agent_protocol.py +19 -0
- mud/agent/character_agent.py +61 -0
- mud/combat/__init__.py +3 -0
- mud/combat/engine.py +189 -0
- mud/commands/__init__.py +3 -0
- mud/commands/admin_commands.py +77 -0
- mud/commands/advancement.py +36 -0
- mud/commands/alias_cmds.py +44 -0
- mud/commands/build.py +18 -0
- mud/commands/combat.py +16 -0
- mud/commands/communication.py +55 -0
- mud/commands/decorators.py +11 -0
- mud/commands/dispatcher.py +206 -0
- mud/commands/healer.py +81 -0
- mud/commands/help.py +14 -0
- mud/commands/imc.py +19 -0
- mud/commands/inspection.py +113 -0
- mud/commands/inventory.py +42 -0
- mud/commands/movement.py +71 -0
- mud/commands/notes.py +44 -0
- mud/commands/shop.py +138 -0
- mud/commands/socials.py +34 -0
- mud/config.py +59 -0
- mud/db/__init__.py +0 -0
- mud/db/init.py +27 -0
- mud/db/migrate_from_files.py +87 -0
- mud/db/migrations.py +7 -0
- mud/db/models.py +98 -0
- mud/db/seed.py +28 -0
- mud/db/session.py +11 -0
- mud/devtools/__init__.py +0 -0
- mud/devtools/agent_demo.py +19 -0
- mud/entrypoint.py +34 -0
- mud/game_loop.py +117 -0
- mud/imc/__init__.py +17 -0
- mud/imc/protocol.py +32 -0
- mud/loaders/__init__.py +27 -0
- mud/loaders/area_loader.py +73 -0
- mud/loaders/base_loader.py +38 -0
- mud/loaders/help_loader.py +17 -0
- mud/loaders/json_area_loader.py +203 -0
- mud/loaders/json_loader.py +285 -0
- mud/loaders/mob_loader.py +104 -0
- mud/loaders/obj_loader.py +76 -0
- mud/loaders/reset_loader.py +29 -0
- mud/loaders/room_loader.py +63 -0
- mud/loaders/shop_loader.py +41 -0
- mud/loaders/social_loader.py +16 -0
- mud/loaders/specials_loader.py +63 -0
- mud/logging/__init__.py +0 -0
- mud/logging/admin.py +40 -0
- mud/logging/agent_trace.py +9 -0
- mud/math/c_compat.py +27 -0
- mud/mobprog.py +72 -0
- mud/models/__init__.py +106 -0
- mud/models/area.py +33 -0
- mud/models/area_json.py +27 -0
- mud/models/board.py +49 -0
- mud/models/board_json.py +16 -0
- mud/models/character.py +195 -0
- mud/models/character_json.py +46 -0
- mud/models/constants.py +423 -0
- mud/models/conversion.py +45 -0
- mud/models/help.py +28 -0
- mud/models/help_json.py +14 -0
- mud/models/json_io.py +64 -0
- mud/models/mob.py +82 -0
- mud/models/note.py +29 -0
- mud/models/note_json.py +16 -0
- mud/models/obj.py +82 -0
- mud/models/object.py +28 -0
- mud/models/object_json.py +40 -0
- mud/models/player_json.py +29 -0
- mud/models/room.py +86 -0
- mud/models/room_json.py +46 -0
- mud/models/shop.py +21 -0
- mud/models/shop_json.py +17 -0
- mud/models/skill.py +25 -0
- mud/models/skill_json.py +20 -0
- mud/models/social.py +78 -0
- mud/models/social_json.py +20 -0
- mud/net/__init__.py +9 -0
- mud/net/ansi.py +27 -0
- mud/net/connection.py +174 -0
- mud/net/protocol.py +57 -0
- mud/net/session.py +21 -0
- mud/net/telnet_server.py +31 -0
- mud/network/__init__.py +0 -0
- mud/network/websocket_server.py +83 -0
- mud/network/websocket_session.py +21 -0
- mud/notes.py +45 -0
- mud/persistence.py +185 -0
- mud/registry.py +5 -0
- mud/scripts/convert_are_to_json.py +162 -0
- mud/scripts/convert_help_are_to_json.py +92 -0
- mud/scripts/convert_player_to_json.py +112 -0
- mud/scripts/convert_shops_to_json.py +64 -0
- mud/scripts/convert_skills_to_json.py +166 -0
- mud/scripts/convert_social_are_to_json.py +92 -0
- mud/scripts/load_test_data.py +17 -0
- mud/security/__init__.py +0 -0
- mud/security/bans.py +112 -0
- mud/security/hash_utils.py +20 -0
- mud/server.py +8 -0
- mud/skills/__init__.py +3 -0
- mud/skills/handlers.py +795 -0
- mud/skills/registry.py +97 -0
- mud/spawning/__init__.py +1 -0
- mud/spawning/mob_spawner.py +13 -0
- mud/spawning/obj_spawner.py +18 -0
- mud/spawning/reset_handler.py +222 -0
- mud/spawning/templates.py +63 -0
- mud/spec_funs.py +57 -0
- mud/time.py +48 -0
- mud/utils/rng_mm.py +123 -0
- mud/wiznet.py +74 -0
- mud/world/__init__.py +11 -0
- mud/world/linking.py +31 -0
- mud/world/look.py +29 -0
- mud/world/movement.py +135 -0
- mud/world/world_state.py +179 -0
- rom24_quickmud_python-1.2.2.dist-info/METADATA +236 -0
- rom24_quickmud_python-1.2.2.dist-info/RECORD +135 -0
- rom24_quickmud_python-1.2.2.dist-info/WHEEL +5 -0
- rom24_quickmud_python-1.2.2.dist-info/entry_points.txt +2 -0
- rom24_quickmud_python-1.2.2.dist-info/licenses/LICENSE +21 -0
- 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()
|
mud/account/__init__.py
ADDED
|
@@ -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
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
|
mud/commands/__init__.py
ADDED