Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 92 additions & 68 deletions PYTHON_PORT_PLAN.md

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions mud/account/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,19 @@ def login_with_host(
This wrapper checks both account and host bans and only then delegates to
the standard login function.
"""
if bans.is_host_banned(host):
if bans.is_host_banned(host, bans.BanFlag.ALL):
return None
return login(username, raw_password)
session = SessionLocal()
existing = session.query(PlayerAccount).filter_by(username=username).first()
session.close()
if existing is None and bans.is_host_banned(host, bans.BanFlag.NEWBIES):
return None
account = login(username, raw_password)
if not account:
return None
if bans.is_host_banned(host, bans.BanFlag.PERMIT) and not getattr(account, "is_admin", False):
return None
return account


def list_characters(account: PlayerAccount) -> List[str]:
Expand Down
89 changes: 85 additions & 4 deletions mud/commands/advancement.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,101 @@
from __future__ import annotations

from mud.models.character import Character
from mud.models.constants import ActFlag, Position, convert_flags_from_letters
from mud.skills.registry import skill_registry


def _has_practice_flag(entity) -> bool:
checker = getattr(entity, "has_act_flag", None)
if callable(checker):
try:
return bool(checker(ActFlag.PRACTICE))
except TypeError:
pass
act_value = getattr(entity, "act", None)
if act_value is not None:
try:
return bool(ActFlag(act_value) & ActFlag.PRACTICE)
except ValueError:
pass
flags = getattr(entity, "act_flags", None)
if isinstance(flags, ActFlag):
return bool(flags & ActFlag.PRACTICE)
if isinstance(flags, int):
return bool(ActFlag(flags) & ActFlag.PRACTICE)
if isinstance(flags, str):
return bool(convert_flags_from_letters(flags, ActFlag) & ActFlag.PRACTICE)
return False


def _is_awake(entity) -> bool:
position = getattr(entity, "position", Position.STANDING)
try:
pos_value = Position(position)
except ValueError:
pos_value = Position.STANDING
return pos_value > Position.SLEEPING


def _find_practice_trainer(char: Character):
room = getattr(char, "room", None)
if room is None:
return None
for occupant in getattr(room, "people", []):
if occupant is char:
continue
if not _has_practice_flag(occupant):
continue
if not _is_awake(occupant):
continue
return occupant
return None


def _rating_for_class(skill, ch_class: int) -> int:
rating = getattr(skill, "rating", {})
if isinstance(rating, dict):
if ch_class in rating:
return int(rating[ch_class])
key = str(ch_class)
if key in rating:
return int(rating[key])
return 1


def do_practice(char: Character, args: str) -> str:
args = (args or "").strip()
if not args:
return f"You have {char.practice} practice sessions left."
if char.is_npc:
return ""
if not char.is_awake():
return "In your dreams, or what?"
trainer = _find_practice_trainer(char)
if trainer is None:
return "You can't do that here."
if char.practice <= 0:
return "You have no practice sessions left."
skill_name = args.lower()
if skill_name not in skill_registry.skills:
skill = skill_registry.skills.get(skill_name)
if skill is None:
return "You can't practice that."
rating = _rating_for_class(skill, char.ch_class)
if rating <= 0:
return "You can't practice that."
current = char.skills.get(skill_name)
if current is None:
return "You can't practice that."
current = char.skills.get(skill_name, 0)
if current >= 75:
adept = char.skill_adept_cap()
if current >= adept:
return f"You are already learned at {skill_name}."
gain_rate = char.get_int_learn_rate()
increment = max(1, gain_rate // max(1, rating))
char.practice -= 1
char.skills[skill_name] = min(current + 25, 75)
new_value = min(adept, current + increment)
char.skills[skill_name] = new_value
if new_value >= adept:
return f"You are now learned at {skill_name}."
return f"You practice {skill_name}."


Expand Down
4 changes: 4 additions & 0 deletions mud/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
Direction,
Sector,
Position,
Stat,
WearLocation,
Sex,
Size,
ItemType,
ActFlag,
)

from .area_json import AreaJson, VnumRangeJson
Expand Down Expand Up @@ -99,8 +101,10 @@
"Direction",
"Sector",
"Position",
"Stat",
"WearLocation",
"Sex",
"Size",
"ItemType",
"ActFlag",
]
91 changes: 90 additions & 1 deletion mud/models/character.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from dataclasses import dataclass, field
from typing import List, Optional, Dict, TYPE_CHECKING

from mud.models.constants import AffectFlag, Position
from mud.models.constants import AffectFlag, Position, Stat

if TYPE_CHECKING:
from mud.models.object import Object
Expand Down Expand Up @@ -124,6 +124,55 @@ def is_immortal(self) -> bool:
effective_level = self.trust if self.trust > 0 else self.level
return effective_level >= LEVEL_IMMORTAL

def is_awake(self) -> bool:
"""Return True if the character is awake (not sleeping or worse)."""

return self.position > Position.SLEEPING

@staticmethod
def _stat_from_list(values: List[int], stat: int) -> Optional[int]:
if not values:
return None
idx = int(stat)
if idx < 0 or idx >= len(values):
return None
val = values[idx]
if val is None:
return None
return int(val)

def get_curr_stat(self, stat: int | Stat) -> Optional[int]:
"""Compute current stat (perm + mod) clamped to ROM 0..25."""

idx = int(stat)
base_val = self._stat_from_list(self.perm_stat, idx)
mod_val = self._stat_from_list(self.mod_stat, idx)
if base_val is None and mod_val is None:
return None
total = (base_val or 0) + (mod_val or 0)
return max(0, min(25, total))

def get_int_learn_rate(self) -> int:
"""Return int_app.learn value for the character's current INT."""

stat_val = self.get_curr_stat(Stat.INT)
if stat_val is None:
return _DEFAULT_INT_LEARN
idx = max(0, min(stat_val, len(_INT_LEARN_RATES) - 1))
return _INT_LEARN_RATES[idx]

def skill_adept_cap(self) -> int:
"""Return the maximum practiced percentage allowed for this character."""

if self.is_npc:
return 100
return _CLASS_SKILL_ADEPT.get(self.ch_class, _CLASS_SKILL_ADEPT_DEFAULT)

def send_to_char(self, message: str) -> None:
"""Append a message to the character's buffer (used in tests)."""

self.messages.append(message)

def add_object(self, obj: "Object") -> None:
self.inventory.append(obj)
self.carry_number += 1
Expand Down Expand Up @@ -220,3 +269,43 @@ def to_orm(character: Character, player_id: int) -> "DBCharacter":
room_vnum=character.room.vnum if character.room else None,
player_id=player_id,
)
_INT_LEARN_RATES: list[int] = [
3,
5,
7,
8,
9,
10,
11,
12,
13,
15,
17,
19,
22,
25,
28,
31,
34,
37,
40,
44,
49,
55,
60,
70,
80,
85,
]

_DEFAULT_INT_LEARN = _INT_LEARN_RATES[13] # INT 13 is baseline in ROM.

_CLASS_SKILL_ADEPT: dict[int, int] = {
0: 75, # mage
1: 75, # cleric
2: 75, # thief
3: 75, # warrior
}

_CLASS_SKILL_ADEPT_DEFAULT = 75

53 changes: 53 additions & 0 deletions mud/models/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ class Position(IntEnum):
STANDING = 8


class Stat(IntEnum):
"""Primary character statistics (STAT_* indexes in merc.h)."""

STR = 0
INT = 1
WIS = 2
DEX = 3
CON = 4


# --- Armor Class indices (merc.h) ---
# AC is better when more negative; indices map to damage types.
AC_PIERCE = 0
Expand Down Expand Up @@ -101,6 +111,14 @@ class Size(IntEnum):
LEVEL_HERO = MAX_LEVEL - 9 # 51
LEVEL_IMMORTAL = MAX_LEVEL - 8 # 52

# Class guild entry rooms (ROM const.c: class_table)
CLASS_GUILD_ROOMS: dict[int, tuple[int, int]] = {
0: (3018, 9618), # mage
1: (3003, 9619), # cleric
2: (3028, 9639), # thief
3: (3022, 9633), # warrior
}


class ItemType(IntEnum):
"""Common object types"""
Expand Down Expand Up @@ -136,6 +154,33 @@ class ItemType(IntEnum):
JUKEBOX = 34


class ActFlag(IntFlag):
"""NPC act flags from ROM merc.h (letters A..Z, aa..dd)."""

IS_NPC = 1 << 0 # (A)
SENTINEL = 1 << 1 # (B)
SCAVENGER = 1 << 2 # (C)
AGGRESSIVE = 1 << 5 # (F)
STAY_AREA = 1 << 6 # (G)
WIMPY = 1 << 7 # (H)
PET = 1 << 8 # (I)
TRAIN = 1 << 9 # (J)
PRACTICE = 1 << 10 # (K)
UNDEAD = 1 << 14 # (O)
CLERIC = 1 << 16 # (Q)
MAGE = 1 << 17 # (R)
THIEF = 1 << 18 # (S)
WARRIOR = 1 << 19 # (T)
NOALIGN = 1 << 20 # (U)
NOPURGE = 1 << 21 # (V)
OUTDOORS = 1 << 22 # (W)
INDOORS = 1 << 24 # (Y)
IS_HEALER = 1 << 26 # (aa)
GAIN = 1 << 27 # (bb)
UPDATE_ALWAYS = 1 << 28 # (cc)
IS_CHANGER = 1 << 29 # (dd)


class RoomFlag(IntFlag):
"""Room flags from ROM merc.h ROOM_* defines"""

Expand Down Expand Up @@ -461,6 +506,14 @@ class ExtraFlag(IntFlag):
# Bits map to letters A..Z; EX_ISDOOR=A (1<<0), EX_CLOSED=B (1<<1)
EX_ISDOOR = 1 << 0
EX_CLOSED = 1 << 1
EX_LOCKED = 1 << 2
EX_PICKPROOF = 1 << 5
EX_NOPASS = 1 << 6
EX_EASY = 1 << 7
EX_HARD = 1 << 8
EX_INFURIATING = 1 << 9
EX_NOCLOSE = 1 << 10
EX_NOLOCK = 1 << 11


def convert_flags_from_letters(flag_letters: str, flag_enum_class) -> int:
Expand Down
28 changes: 28 additions & 0 deletions mud/models/mob.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
if TYPE_CHECKING:
from .area import Area

from mud.models.constants import ActFlag, convert_flags_from_letters

@dataclass
class MobProgram:
"""Representation of MPROG_LIST"""
Expand Down Expand Up @@ -81,5 +83,31 @@ class MobIndex:
def __repr__(self) -> str:
return f"<MobIndex vnum={self.vnum} name={self.short_descr!r}>"

def get_act_flags(self) -> ActFlag:
"""Return act flags as an IntFlag, converting from ROM letters on demand."""

raw = getattr(self, "_act_cache", None)
if isinstance(raw, ActFlag):
return raw
if isinstance(self.act_flags, ActFlag):
self._act_cache = self.act_flags
return self.act_flags
if isinstance(self.act_flags, int):
flags = ActFlag(self.act_flags)
self._act_cache = flags
return flags
if isinstance(self.act_flags, str):
flags = convert_flags_from_letters(self.act_flags, ActFlag)
# Cache both numeric and enum forms for future lookups
self.act = int(flags)
self._act_cache = flags
self.act_flags = flags
return flags
self._act_cache = ActFlag(0)
return ActFlag(0)

def has_act_flag(self, flag: ActFlag) -> bool:
return bool(self.get_act_flags() & flag)


mob_registry: dict[int, MobIndex] = {}
Loading