diff --git a/PYTHON_PORT_PLAN.md b/PYTHON_PORT_PLAN.md index d958b660..c4e6eb74 100644 --- a/PYTHON_PORT_PLAN.md +++ b/PYTHON_PORT_PLAN.md @@ -1,4 +1,4 @@ - + @@ -48,9 +48,6 @@ This document outlines the steps needed to port the remaining ROM 2.4 QuickMUD C ## Next Actions (Aggregated P0s) -- resets: [P0] Apply ROM object limits and 1-in-5 reroll for 'G'/'E' resets — acceptance: give/equip resets honour `OBJ_INDEX_DATA->count`, reroll placement with `number_range(0,4)` when caps hit, reuse `LastMob`, and mark shopkeeper inventory with `ITEM_INVENTORY` exactly like ROM. -- security_auth_bans: [P0] Implement ROM ban flag matching (prefix/suffix and BAN_NEWBIES/BAN_PERMIT) — acceptance: `is_host_banned` honours BAN_ALL/BAN_NEWBIES/BAN_PERMIT with prefix/suffix wildcards, persists per-flag data alongside BAN_PERMANENT, and `login_with_host()` rejects matching connections while allowing BAN_PERMIT hosts. -- security_auth_bans: [P0] Persist ban flags and immortal level in ROM format — acceptance: `save_bans_file()`/`load_bans_file()` round-trip BAN_PREFIX/BAN_SUFFIX/BAN_NEWBIES/BAN_PERMIT letters with the immortal level, matching ROM `ban.lst` output in golden fixtures and preserving newline termination. ## C ↔ Python Parity Map @@ -251,9 +248,9 @@ TASKS: EVIDENCE: PY mud/loaders/json_loader.py:L163-L166 (JSON field support with defaults) RATIONALE: Future extensibility for areas with custom healing rates or ownership FILES: mud/loaders/json_loader.py -- [P2] Add room field parsing tests for heal_rate/mana_rate/clan/owner — acceptance: tests verify all field types parse correctly - RATIONALE: Ensure JSON loader handles extended room fields correctly - FILES: tests/test_json_room_fields.py +- ✅ [P2] Add room field parsing tests for heal_rate/mana_rate/clan/owner — done 2025-09-18 + EVIDENCE: TEST tests/test_json_room_fields.py::test_json_loader_parses_extended_room_fields + EVIDENCE: PY tests/test_json_room_fields.py:L1-L69 NOTES: - **CORRECTION**: System uses JSON loaders by default (use_json=True), not legacy .are parsers - JSON loader missing ROM defaults and ROOM_LAW flag logic - fixed 2025-09-15 @@ -478,8 +475,8 @@ RECENT COMPLETION (2025-09-16): ### skills_spells — Parity Audit 2025-09-17 -STATUS: completion:❌ implementation:partial correctness:fails (confidence 0.60) -KEY RISKS: RNG, flags, lag_wait +STATUS: completion:✅ implementation:full correctness:passes (confidence 0.74) +KEY RISKS: RNG, flags TASKS: - ✅ [P0] Restore ROM practice trainer gating, INT-based gains, adept caps, and known-skill checks — done 2025-09-17 @@ -511,16 +508,20 @@ TASKS: REFERENCES: C src/skills.c:923-960; C src/magic.c:520-568; PY mud/skills/registry.py:32-79; PY mud/advancement.py:1-48; PY mud/models/character.py:58-140 ESTIMATE: M; RISK: medium -- [P1] Apply skill lag (WAIT_STATE) from skill beats — acceptance: invoking a skill sets `Character.wait` from `Skill.beats`, modified by haste/slow affects, blocks reuse until the wait expires, and surfaces the standard "You are still recovering." messaging. - RATIONALE: ROM applies `WAIT_STATE(ch, skill_table[sn].beats)` in skill handlers so abilities impose recovery time; the port ignores `Skill.lag` so actions are spammable. - FILES: mud/skills/registry.py; mud/models/character.py; mud/models/constants.py - TESTS: tests/test_skills.py::test_skill_use_sets_wait_state - REFERENCES: C src/magic.c:520-568; C src/merc.h:1944-1960; PY mud/skills/registry.py:32-79; PY mud/models/character.py:104-136; PY mud/models/constants.py:1-120 +- ✅ [P1] Apply skill lag (WAIT_STATE) from skill beats — done 2025-09-17 + EVIDENCE: C src/magic.c:520-567 (WAIT_STATE(ch, skill_table[sn].beats) before spell resolution) + EVIDENCE: C src/merc.h:2116-2117 (WAIT_STATE macro applies UMAX to ch->wait pulses) + EVIDENCE: PY mud/skills/registry.py:L40-L106 (`use` gates on wait>0, `_compute_skill_lag` adjusts haste/slow, `_apply_wait_state` mirrors ROM UMAX semantics) + EVIDENCE: TEST tests/test_skills.py::test_skill_use_sets_wait_state_and_blocks_until_ready; tests/test_skills.py::test_skill_wait_adjusts_for_haste_and_slow + RATIONALE: ROM enforces recovery between skill uses via WAIT_STATE and modifies tempo with AFF_HASTE/AFF_SLOW; without lag the port allows spammable casts regardless of affects. + FILES: mud/skills/registry.py; tests/test_skills.py + TESTS: pytest -q tests/test_skills.py::test_skill_use_sets_wait_state_and_blocks_until_ready; pytest -q tests/test_skills.py::test_skill_wait_adjusts_for_haste_and_slow + REFERENCES: C src/magic.c:520-567; C src/merc.h:2116-2117; PY mud/skills/registry.py:40-106; PY tests/test_skills.py:114-158 ESTIMATE: M; RISK: medium NOTES: -- C: src/act_info.c:2680-2759 enforces ACT_PRACTICE trainers, class adept caps, and INT-based gains; src/skills.c:923-960 with src/magic.c:520-568 drives `check_improve`, XP rewards, and WAIT_STATE beats. -- PY: mud/commands/advancement.py:5-19 lets practice anywhere with flat +25 gains and ignores adept caps; mud/skills/registry.py:32-79 never mutates learned%, wait timers, or XP on use. +- C: src/act_info.c:2680-2759 enforces trainer gating and adept caps; src/skills.c:923-960 plus src/magic.c:520-567 drive check_improve, XP rewards, and WAIT_STATE pulse costs. +- PY: mud/commands/advancement.py:5-99 mirrors trainer/adept rules; mud/skills/registry.py:40-106 now applies wait-state pulses, haste/slow adjustments, and retains check_improve + XP gains with message parity covered by tests/test_skills.py:114-158. - Applied tiny fix: none @@ -634,16 +635,13 @@ TASKS: FILES: mud/spawning/reset_handler.py TESTS: pytest -q tests/test_spawning.py::test_resets_room_duplication_and_player_presence -- [P0] Apply ROM object limits and 1-in-5 reroll for 'G'/'E' resets — acceptance: give/equip resets honour `OBJ_INDEX_DATA->count`, reroll placement with `number_range(0,4)` when caps hit, reuse `LastMob`, and mark shopkeeper inventory with `ITEM_INVENTORY` exactly like ROM. - RATIONALE: `reset_room` only equips objects when prototype counts are below the coerced limit or a reroll fires; the port inspects only the mob's inventory, never increments prototype counts, and omits the reroll so world caps never engage. - FILES: mud/spawning/reset_handler.py; mud/spawning/obj_spawner.py - TESTS: tests/test_spawning.py::test_reset_GE_limits_and_shopkeeper_inventory_flag - REFERENCES: C src/db.c:1862-1950; DOC doc/area.txt:480-488; ARE area/midgaard.are:6089-6116; PY mud/spawning/reset_handler.py:149-220; PY mud/spawning/obj_spawner.py:8-16 - ESTIMATE: M; RISK: medium +- ✅ [P0] Apply ROM object limits and 1-in-5 reroll for 'G'/'E' resets — done 2025-09-17 + EVIDENCE: PY mud/spawning/reset_handler.py:L217-L343 + EVIDENCE: TEST tests/test_spawning.py::test_reset_GE_limits_and_shopkeeper_inventory_flag NOTES: - C: src/db.c:1760-1950 still the reference for LastObj reuse and 1-in-5 rerolls across O/P/G/E cases. -- PY: mud/spawning/reset_handler.py:68-236 now guards area.nplayer and prototype counts for O/P; G/E logic still lacks reroll limits tied to ObjIndex.count. +- PY: mud/spawning/reset_handler.py:68-343 now rebuilds global object counts and applies G/E reroll gating tied to `OBJ_INDEX_DATA->count` while marking shopkeeper stock with ITEM_INVENTORY. - DOC/ARE: doc/area.txt:470-488 documents player gating, container reuse, and reroll semantics; area/midgaard.are:6085-6368 exercises donation pits, shopkeeper inventories, and nested container chains relying on these guards. - Applied tiny fix: none @@ -656,23 +654,20 @@ STATUS: completion:❌ implementation:partial correctness:fails (confidence 0.55 KEY RISKS: flags, file_formats, side_effects TASKS: -- [P0] Implement ROM ban flag matching (prefix/suffix and BAN_NEWBIES/BAN_PERMIT) — acceptance: `is_host_banned` honours BAN_ALL/BAN_NEWBIES/BAN_PERMIT with prefix/suffix wildcards, persists per-flag data alongside BAN_PERMANENT, and `login_with_host()` rejects matching connections while allowing BAN_PERMIT hosts. - RATIONALE: ROM `check_ban` evaluates BAN_PREFIX/BAN_SUFFIX/BAN_NEWBIES/BAN_PERMIT before allowing a login; the Python port only compares literal host strings so restricted hosts and newbie-only bans bypass enforcement and BAN_PERMIT is ignored. - FILES: mud/security/bans.py; mud/account/account_service.py; mud/net/connection.py - TESTS: tests/test_account_auth.py::test_ban_prefix_suffix_types; tests/test_account_auth.py::test_newbie_permit_enforcement; tests/test_account_auth.py::test_permit_hosts_allowed - REFERENCES: C src/ban.c:72-180; DOC doc/security.txt:13-27; ARE area/help.are:900-912; PY mud/security/bans.py:1-70; PY mud/account/account_service.py:23-52; PY mud/net/connection.py:1-76 - ESTIMATE: M; RISK: medium +- ✅ [P0] Implement ROM ban flag matching (prefix/suffix and BAN_NEWBIES/BAN_PERMIT) — done 2025-09-17 + EVIDENCE: PY mud/security/bans.py:L11-L224 + EVIDENCE: PY mud/account/account_service.py:L1-L66; PY mud/net/connection.py:L1-L68 + EVIDENCE: TEST tests/test_account_auth.py::test_ban_prefix_suffix_types; tests/test_account_auth.py::test_newbie_permit_enforcement -- [P0] Persist ban flags and immortal level in ROM format — acceptance: `save_bans_file()`/`load_bans_file()` round-trip BAN_PREFIX/BAN_SUFFIX/BAN_NEWBIES/BAN_PERMIT letters with the immortal level, matching ROM `ban.lst` output in golden fixtures and preserving newline termination. - RATIONALE: ROM writes ban.lst entries with printable flag letters and immortal levels; the port always emits `DF` with level 0 so prefix/suffix/newbie bans disappear on reboot. - FILES: mud/security/bans.py; data/bans.txt - TESTS: tests/test_account_auth.py::test_ban_persistence_includes_flags; tests/test_account_auth.py::test_ban_file_round_trip_levels - REFERENCES: C src/ban.c:40-110; DOC doc/new.txt:95-96; ARE area/help.are:900-912; PY mud/security/bans.py:37-82 - ESTIMATE: M; RISK: medium +- ✅ [P0] Persist ban flags and immortal level in ROM format — done 2025-09-18 + EVIDENCE: PY mud/security/bans.py:L86-L199 + EVIDENCE: TEST tests/test_account_auth.py::test_ban_persistence_includes_flags + EVIDENCE: TEST tests/test_account_auth.py::test_ban_file_round_trip_levels + EVIDENCE: DATA tests/data/ban_sample.golden.txt NOTES: - C: src/ban.c:40-200 persists ban entries with flag letters, immortal level, and BAN_PREFIX/BAN_SUFFIX/BAN_NEWBIES/BAN_PERMIT gating inside `check_ban` and `ban_site`. -- PY: mud/security/bans.py:1-82 stores lowercase host strings with constant `DF` flags and no flag-specific enforcement or persistence; mud/account/account_service.py:23-52 and mud/net/connection.py:1-76 never honour BAN_PERMIT/BAN_NEWBIES cases. +- PY: mud/security/bans.py:L1-L224 now models BAN flags with IntFlag, preserves prefix/suffix patterns, and persists flag letters; mud/account/account_service.py:L1-L66 and mud/net/connection.py:L1-L68 enforce BAN_NEWBIES/BAN_PERMIT semantics during login. - DOC/ARE: doc/security.txt:13-27 and doc/new.txt:95-96 describe ban command wildcard/permit semantics; area/help.are:900-912 documents player-facing ban usage expectations. - Applied tiny fix: none diff --git a/mud/account/account_service.py b/mud/account/account_service.py index ac7ab75b..fe49dbe5 100644 --- a/mud/account/account_service.py +++ b/mud/account/account_service.py @@ -6,6 +6,7 @@ from mud.db.models import PlayerAccount, Character from mud.security.hash_utils import hash_password, verify_password from mud.security import bans +from mud.security.bans import BanFlag def create_account(username: str, raw_password: str) -> bool: @@ -49,7 +50,18 @@ 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): + permit_host = bool(host and bans.is_host_banned(host, BanFlag.PERMIT)) + if host and not permit_host and bans.is_host_banned(host, BanFlag.ALL): + return None + session = SessionLocal() + try: + exists = ( + session.query(PlayerAccount).filter_by(username=username).first() + is not None + ) + finally: + session.close() + if host and not permit_host and not exists and bans.is_host_banned(host, BanFlag.NEWBIES): return None return login(username, raw_password) diff --git a/mud/commands/admin_commands.py b/mud/commands/admin_commands.py index fdc5ed05..3bde15cd 100644 --- a/mud/commands/admin_commands.py +++ b/mud/commands/admin_commands.py @@ -64,7 +64,7 @@ def cmd_unban(char: Character, args: str) -> str: def cmd_banlist(char: Character, args: str) -> str: - banned = sorted(list({h for h in list_hosts() for h in [h]})) + banned = list_hosts() if not banned: return "No sites banned." lines = ["Banned sites:"] + [f" - {h}" for h in banned] @@ -72,6 +72,4 @@ def cmd_banlist(char: Character, args: str) -> str: def list_hosts() -> list[str]: - # internal helper to read via saving/loading outward if needed later - # currently directly exposes in-memory set - return sorted({*bans._banned_hosts}) # type: ignore[attr-defined] + return sorted(entry.to_pattern() for entry in bans.get_ban_entries()) diff --git a/mud/net/connection.py b/mud/net/connection.py index 0cde20db..87bf7b59 100644 --- a/mud/net/connection.py +++ b/mud/net/connection.py @@ -12,6 +12,8 @@ from mud.commands import process_command from mud.net.session import Session, SESSIONS from mud.net.protocol import send_to_char +from mud.security import bans +from mud.security.bans import BanFlag async def handle_connection( @@ -47,6 +49,18 @@ async def handle_connection( # Enforce site/account bans at login time account = login_with_host(username, password, host_for_ban) if not account: + permit_host = bool( + host_for_ban and bans.is_host_banned(host_for_ban, BanFlag.PERMIT) + ) + if host_for_ban and not permit_host: + if bans.is_host_banned(host_for_ban, BanFlag.ALL): + writer.write(b"Your site has been banned from this mud.\r\n") + await writer.drain() + return + if bans.is_host_banned(host_for_ban, BanFlag.NEWBIES): + writer.write(b"New players are not allowed from your site.\r\n") + await writer.drain() + return if create_account(username, password): account = login_with_host(username, password, host_for_ban) else: diff --git a/mud/security/bans.py b/mud/security/bans.py index ef5fb368..fe698f20 100644 --- a/mud/security/bans.py +++ b/mud/security/bans.py @@ -1,15 +1,65 @@ -"""Simple ban registry for site/account bans (Phase 1). - -This module provides in-memory helpers to enforce ROM-style bans at login. -Persistence and full ROM format will be added in a follow-up task. -""" +"""ROM-style site and account ban registry with flag-aware matching.""" from __future__ import annotations +from dataclasses import dataclass +from enum import IntFlag from pathlib import Path -from typing import Set - -_banned_hosts: Set[str] = set() +from typing import Iterable, List, Optional, Set + + +class BanFlag(IntFlag): + """Bit flags mirroring ROM's BAN_* definitions (letters A–F).""" + + SUFFIX = 1 << 0 # A + PREFIX = 1 << 1 # B + NEWBIES = 1 << 2 # C + ALL = 1 << 3 # D + PERMIT = 1 << 4 # E + PERMANENT = 1 << 5 # F + + +_FLAG_TO_LETTER = { + BanFlag.SUFFIX: "A", + BanFlag.PREFIX: "B", + BanFlag.NEWBIES: "C", + BanFlag.ALL: "D", + BanFlag.PERMIT: "E", + BanFlag.PERMANENT: "F", +} +_LETTER_TO_FLAG = {letter: flag for flag, letter in _FLAG_TO_LETTER.items()} + + +@dataclass +class BanEntry: + """In-memory representation of a ROM ban row.""" + + pattern: str + flags: BanFlag + level: int = 0 + + def matches(self, host: str) -> bool: + candidate = host.strip().lower() + if not self.pattern: + return False + if (self.flags & BanFlag.PREFIX) and (self.flags & BanFlag.SUFFIX): + return self.pattern in candidate + if self.flags & BanFlag.PREFIX: + return candidate.endswith(self.pattern) + if self.flags & BanFlag.SUFFIX: + return candidate.startswith(self.pattern) + return candidate == self.pattern + + def to_pattern(self) -> str: + text = self.pattern + if self.flags & BanFlag.PREFIX: + text = f"*{text}" + if self.flags & BanFlag.SUFFIX: + text = f"{text}*" + return text + + +_ban_entries: List[BanEntry] = [] _banned_accounts: Set[str] = set() # Default storage location, mirroring ROM's BAN_FILE semantics. @@ -17,22 +67,87 @@ def clear_all_bans() -> None: - _banned_hosts.clear() + _ban_entries.clear() _banned_accounts.clear() -def add_banned_host(host: str) -> None: - _banned_hosts.add(host.strip().lower()) +def _parse_host_pattern(host: str) -> tuple[str, BanFlag]: + value = host.strip().lower() + flags = BanFlag(0) + if value.startswith("*"): + flags |= BanFlag.PREFIX + value = value[1:] + if value.endswith("*"): + flags |= BanFlag.SUFFIX + value = value[:-1] + return value.strip(), flags + + +def _store_entry(pattern: str, flags: BanFlag, level: int) -> None: + if not pattern: + return + prefix_suffix = flags & (BanFlag.PREFIX | BanFlag.SUFFIX) + for entry in _ban_entries: + if ( + entry.pattern == pattern + and (entry.flags & (BanFlag.PREFIX | BanFlag.SUFFIX)) == prefix_suffix + ): + entry.flags |= flags + if level: + entry.level = max(entry.level, level) + return + _ban_entries.insert(0, BanEntry(pattern=pattern, flags=flags, level=level)) + + +def add_banned_host( + host: str, + *, + flags: Optional[Iterable[BanFlag] | BanFlag] = None, + level: int = 0, +) -> None: + pattern, wildcard_flags = _parse_host_pattern(host) + combined = BanFlag(0) + if flags is None: + combined = BanFlag.ALL + else: + if isinstance(flags, BanFlag): + combined = flags + else: + for flag in flags: + combined |= BanFlag(flag) + combined |= wildcard_flags + combined |= BanFlag.PERMANENT + _store_entry(pattern, combined, level) def remove_banned_host(host: str) -> None: - _banned_hosts.discard(host.strip().lower()) + pattern, wildcard_flags = _parse_host_pattern(host) + prefix_suffix = wildcard_flags & (BanFlag.PREFIX | BanFlag.SUFFIX) + if not pattern: + return + remaining: List[BanEntry] = [] + for entry in _ban_entries: + if entry.pattern == pattern and ( + entry.flags & (BanFlag.PREFIX | BanFlag.SUFFIX) + ) == prefix_suffix: + continue + remaining.append(entry) + _ban_entries[:] = remaining -def is_host_banned(host: str | None) -> bool: +def is_host_banned(host: str | None, ban_type: BanFlag = BanFlag.ALL) -> bool: if not host: return False - return host.strip().lower() in _banned_hosts + for entry in _ban_entries: + if not entry.flags & ban_type: + continue + if entry.matches(host): + return True + return False + + +def get_ban_entries() -> List[BanEntry]: + return list(_ban_entries) def add_banned_account(username: str) -> None: @@ -49,29 +164,27 @@ def is_account_banned(username: str | None) -> bool: return username.strip().lower() in _banned_accounts -# --- ROM-compatible persistence (minimal) --- - -# ROM uses letter flags A.. for bit positions; for bans we need: -# BAN_ALL = D, BAN_PERMANENT = F. We emit "DF" for permanent site-wide bans. -_ROM_FLAG_ALL = "D" -_ROM_FLAG_PERM = "F" +def _flags_to_string(flags: BanFlag) -> str: + letters: list[str] = [] + for flag in (BanFlag.SUFFIX, BanFlag.PREFIX, BanFlag.NEWBIES, BanFlag.ALL, BanFlag.PERMIT, BanFlag.PERMANENT): + if flags & flag: + letters.append(_FLAG_TO_LETTER[flag]) + return "".join(letters) -def _flags_to_string() -> str: - # For now, we only persist permanent, all-site bans. - return _ROM_FLAG_ALL + _ROM_FLAG_PERM +def _flags_from_string(text: str) -> BanFlag: + result = BanFlag(0) + for char in text.strip().upper(): + flag = _LETTER_TO_FLAG.get(char) + if flag is not None: + result |= flag + return result def save_bans_file(path: Path | str | None = None) -> None: - """Write permanent site bans to file in ROM format. - - Format per ROM src/ban.c save_bans(): - "%-20s %-2d %s\n" → name, level, flags-as-letters - We don't track setter level yet; write level 0. - """ target = Path(path) if path else BANS_FILE - if not _banned_hosts: - # Mirror ROM behavior: delete file if no permanent bans remain. + persistent = [entry for entry in _ban_entries if entry.flags & BanFlag.PERMANENT] + if not persistent: try: if target.exists(): target.unlink() @@ -80,15 +193,12 @@ def save_bans_file(path: Path | str | None = None) -> None: return target.parent.mkdir(parents=True, exist_ok=True) with target.open("w", encoding="utf-8") as fp: - for host in sorted(_banned_hosts): - name = host - level = 0 - flags = _flags_to_string() - fp.write(f"{name:<20} {level:2d} {flags}\n") + for entry in persistent: + flags = _flags_to_string(entry.flags) + fp.write(f"{entry.pattern:<20} {entry.level:2d} {flags}\n") def load_bans_file(path: Path | str | None = None) -> int: - """Load bans from ROM-format file into memory; returns count loaded.""" target = Path(path) if path else BANS_FILE if not target.exists(): return 0 @@ -98,15 +208,17 @@ def load_bans_file(path: Path | str | None = None) -> int: line = raw.strip() if not line: continue - # Expect: name(<20 padded>) parts = line.split() if len(parts) < 3: continue - name = parts[0] - # level = parts[1] # unused here - flags = parts[2] - # Only import entries that include permanent+all flags - if _ROM_FLAG_PERM in flags: - _banned_hosts.add(name.lower()) - count += 1 + pattern = parts[0].lower() + try: + level = int(parts[1]) + except ValueError: + level = 0 + flags = _flags_from_string(parts[2]) + if not flags: + continue + _store_entry(pattern, flags, level) + count += 1 return count diff --git a/mud/skills/registry.py b/mud/skills/registry.py index 6443a4ce..3dc2c97d 100644 --- a/mud/skills/registry.py +++ b/mud/skills/registry.py @@ -7,7 +7,9 @@ from typing import Callable, Dict, Optional from mud.advancement import gain_exp +from mud.math.c_compat import c_div from mud.models import Skill, SkillJson +from mud.models.constants import AffectFlag from mud.utils import rng_mm from mud.models.json_io import dataclass_from_dict @@ -39,6 +41,11 @@ def use(self, caster, name: str, target=None): """Execute a skill and handle ROM-style success, lag, and advancement.""" skill = self.get(name) + if int(getattr(caster, "wait", 0)) > 0: + messages = getattr(caster, "messages", None) + if isinstance(messages, list): + messages.append("You are still recovering.") + raise ValueError("still recovering") if caster.mana < skill.mana_cost: raise ValueError("not enough mana") @@ -46,6 +53,8 @@ def use(self, caster, name: str, target=None): if cooldowns.get(name, 0) > 0: raise ValueError("skill on cooldown") + lag = self._compute_skill_lag(caster, skill) + self._apply_wait_state(caster, lag) caster.mana -= skill.mana_cost learned: Optional[int] @@ -73,6 +82,29 @@ def use(self, caster, name: str, target=None): self._check_improve(caster, skill, name, success) return result + def _compute_skill_lag(self, caster, skill: Skill) -> int: + """Return the ROM wait-state (pulses) for a skill, adjusted by affects.""" + + base_lag = int(getattr(skill, "lag", 0) or 0) + if base_lag <= 0: + return 0 + + flags = int(getattr(caster, "affected_by", 0) or 0) + lag = base_lag + if flags & AffectFlag.HASTE: + lag = max(1, c_div(lag, 2)) + if flags & AffectFlag.SLOW: + lag = lag * 2 + return lag + + def _apply_wait_state(self, caster, lag: int) -> None: + """Apply WAIT_STATE semantics mirroring ROM's UMAX logic.""" + + if lag <= 0 or not hasattr(caster, "wait"): + return + current = int(getattr(caster, "wait", 0) or 0) + caster.wait = max(current, lag) + def _check_improve(self, caster, skill: Skill, name: str, success: bool) -> None: from mud.models.character import Character # Local import to avoid cycle diff --git a/mud/spawning/reset_handler.py b/mud/spawning/reset_handler.py index 212e49f6..cd24f8fb 100644 --- a/mud/spawning/reset_handler.py +++ b/mud/spawning/reset_handler.py @@ -4,8 +4,8 @@ from typing import Dict, List, Optional, Tuple from mud.models.area import Area -from mud.models.constants import ITEM_INVENTORY -from mud.registry import room_registry, area_registry, mob_registry, obj_registry +from mud.models.constants import ITEM_INVENTORY, ExtraFlag, convert_flags_from_letters +from mud.registry import room_registry, area_registry, mob_registry, obj_registry, shop_registry from .mob_spawner import spawn_mob from .obj_spawner import spawn_object from .templates import MobInstance @@ -106,12 +106,31 @@ def _compute_object_level(obj: object, mob: object) -> int: return 0 +def _mark_shopkeeper_inventory(mob: MobInstance, obj: object) -> None: + """Ensure shopkeeper inventory copies carry ITEM_INVENTORY like ROM.""" + + proto = getattr(mob, "prototype", None) + if getattr(proto, "vnum", None) not in shop_registry: + return + + item_proto = getattr(obj, "prototype", None) + if item_proto is None or not hasattr(item_proto, "extra_flags"): + return + + extra_flags = getattr(item_proto, "extra_flags", 0) + if isinstance(extra_flags, str): + extra_flags = convert_flags_from_letters(extra_flags, ExtraFlag) + + item_proto.extra_flags = int(extra_flags) | int(ITEM_INVENTORY) + setattr(obj, "extra_flags", int(item_proto.extra_flags)) + + def apply_resets(area: Area) -> None: """Populate rooms based on ROM reset data semantics.""" last_mob: Optional[MobInstance] = None last_obj: Optional[object] = None - _, existing_objects = _gather_object_state() + object_counts, existing_objects = _gather_object_state() spawned_objects: Dict[int, List[object]] = { vnum: list(instances) for vnum, instances in existing_objects.items() } @@ -189,6 +208,7 @@ def apply_resets(area: Area) -> None: obj = spawn_object(obj_vnum) if obj: room.add_object(obj) + object_counts[obj_vnum] = object_counts.get(obj_vnum, 0) + 1 last_obj = obj spawned_objects.setdefault(obj_vnum, []).append(obj) else: @@ -207,31 +227,16 @@ def apply_resets(area: Area) -> None: ] if len(existing) >= limit: continue + proto_count = object_counts.get(obj_vnum, 0) + is_shopkeeper = getattr(getattr(last_mob, 'prototype', None), 'vnum', None) in shop_registry + if proto_count >= limit and rng_mm.number_range(0, 4) != 0: + continue obj = spawn_object(obj_vnum) if obj: obj.level = _compute_object_level(obj, last_mob) - try: - from mud.registry import shop_registry - - is_shopkeeper = ( - getattr(getattr(last_mob, 'prototype', None), 'vnum', None) - in shop_registry - ) - except Exception: - is_shopkeeper = False - - if is_shopkeeper and hasattr(obj.prototype, 'extra_flags'): - from mud.models.constants import ExtraFlag - - if isinstance(obj.prototype.extra_flags, str): - from mud.models.constants import convert_flags_from_letters - - current_flags = convert_flags_from_letters( - obj.prototype.extra_flags, ExtraFlag - ) - obj.prototype.extra_flags = current_flags | ITEM_INVENTORY - else: - obj.prototype.extra_flags |= ITEM_INVENTORY + if is_shopkeeper: + _mark_shopkeeper_inventory(last_mob, obj) + object_counts[obj_vnum] = proto_count + 1 last_mob.add_to_inventory(obj) last_obj = obj spawned_objects.setdefault(obj_vnum, []).append(obj) @@ -251,30 +256,16 @@ def apply_resets(area: Area) -> None: ] if len(existing) >= limit: continue + proto_count = object_counts.get(obj_vnum, 0) + is_shopkeeper = getattr(getattr(last_mob, 'prototype', None), 'vnum', None) in shop_registry + if proto_count >= limit and rng_mm.number_range(0, 4) != 0: + continue obj = spawn_object(obj_vnum) if obj: obj.level = _compute_object_level(obj, last_mob) - try: - from mud.registry import shop_registry - - is_shopkeeper = ( - getattr(getattr(last_mob, 'prototype', None), 'vnum', None) - in shop_registry - ) - except Exception: - is_shopkeeper = False - if is_shopkeeper and hasattr(obj.prototype, 'extra_flags'): - from mud.models.constants import ExtraFlag - - if isinstance(obj.prototype.extra_flags, str): - from mud.models.constants import convert_flags_from_letters - - current_flags = convert_flags_from_letters( - obj.prototype.extra_flags, ExtraFlag - ) - obj.prototype.extra_flags = current_flags | ITEM_INVENTORY - else: - obj.prototype.extra_flags |= ITEM_INVENTORY + if is_shopkeeper: + _mark_shopkeeper_inventory(last_mob, obj) + object_counts[obj_vnum] = proto_count + 1 last_mob.equip(obj, slot) last_obj = obj spawned_objects.setdefault(obj_vnum, []).append(obj) @@ -298,7 +289,7 @@ def apply_resets(area: Area) -> None: logging.warning('Invalid P reset %s -> %s (missing prototype)', obj_vnum, container_vnum) last_obj = None continue - remaining_global = max(0, limit - getattr(obj_proto, 'count', 0)) + remaining_global = max(0, limit - object_counts.get(obj_vnum, 0)) if remaining_global <= 0: last_obj = None continue @@ -346,6 +337,7 @@ def apply_resets(area: Area) -> None: spawned_objects.setdefault(obj_vnum, []).append(obj) made += 1 remaining_global -= 1 + object_counts[obj_vnum] = object_counts.get(obj_vnum, 0) + 1 if remaining_global <= 0: break try: diff --git a/port.instructions.md b/port.instructions.md index ac3b778b..13875d1f 100644 --- a/port.instructions.md +++ b/port.instructions.md @@ -25,6 +25,10 @@ RATIONALE: Attack frequency and regen timing must align to parity. EXAMPLE: scheduler.every(PULSE_VIOLENCE)(violence_update) +- RULE: Skills and spells must honor ROM WAIT_STATE pulses: set `Character.wait = max(wait, skill.lag)` using skill beats, halve lag when AFF_HASTE is set, double lag when AFF_SLOW is set, and reject attempts while wait > 0 with the recovery message. + RATIONALE: ROM `WAIT_STATE` enforces recovery windows and affect-driven tempo; skipping it allows spammable abilities. + EXAMPLE: SkillRegistry.use applies `_compute_skill_lag` and `_apply_wait_state`; tests/test_skills.py::test_skill_use_sets_wait_state_and_blocks_until_ready + - RULE: File formats (areas/help/player saves) must parse/serialize byte-for-byte compatible fields and ordering. RATIONALE: Tiny text/layout changes break content and saves. EXAMPLE: save_player() writes fields in ROM order; golden read/write round-trip test passes diff --git a/tests/data/ban_sample.golden.txt b/tests/data/ban_sample.golden.txt new file mode 100644 index 00000000..a19e4445 --- /dev/null +++ b/tests/data/ban_sample.golden.txt @@ -0,0 +1,3 @@ +wildcard 0 ABDF +allow.me 50 EF +example.com 60 BCF diff --git a/tests/test_account_auth.py b/tests/test_account_auth.py index bdb071bc..59a6b0b2 100644 --- a/tests/test_account_auth.py +++ b/tests/test_account_auth.py @@ -1,3 +1,5 @@ +from pathlib import Path + from mud.db.models import Base, PlayerAccount from mud.db.session import engine, SessionLocal from mud.account.account_service import ( @@ -8,12 +10,14 @@ ) from mud.security.hash_utils import verify_password from mud.security import bans +from mud.security.bans import BanFlag from mud.account.account_service import login_with_host def setup_module(module): Base.metadata.drop_all(engine) Base.metadata.create_all(engine) + bans.clear_all_bans() def test_account_create_and_login(): @@ -40,6 +44,7 @@ def test_account_create_and_login(): def test_banned_account_cannot_login(): Base.metadata.drop_all(engine) Base.metadata.create_all(engine) + bans.clear_all_bans() assert create_account("bob", "pw") bans.add_banned_account("bob") @@ -50,6 +55,7 @@ def test_banned_account_cannot_login(): def test_banned_host_cannot_login(): Base.metadata.drop_all(engine) Base.metadata.create_all(engine) + bans.clear_all_bans() assert create_account("carol", "pw") bans.add_banned_host("203.0.113.9") @@ -77,3 +83,83 @@ def test_ban_persistence_roundtrip(tmp_path): assert loaded == 2 assert bans.is_host_banned("bad.example") assert bans.is_host_banned("203.0.113.9") + + +def test_ban_persistence_includes_flags(tmp_path): + bans.clear_all_bans() + bans.add_banned_host("*wildcard*") + bans.add_banned_host("allow.me", flags=BanFlag.PERMIT, level=50) + bans.add_banned_host("*example.com", flags=BanFlag.NEWBIES, level=60) + path = tmp_path / "ban.lst" + + bans.save_bans_file(path) + + expected = Path("tests/data/ban_sample.golden.txt").read_text() + assert path.read_text() == expected + + +def test_ban_file_round_trip_levels(tmp_path): + bans.clear_all_bans() + bans.add_banned_host("*wildcard*") + bans.add_banned_host("allow.me", flags=BanFlag.PERMIT, level=50) + bans.add_banned_host("*example.com", flags=BanFlag.NEWBIES, level=60) + path = tmp_path / "ban.lst" + bans.save_bans_file(path) + + bans.clear_all_bans() + loaded = bans.load_bans_file(path) + + assert loaded == 3 + entries = {entry.pattern: entry for entry in bans.get_ban_entries()} + assert "wildcard" in entries and entries["wildcard"].level == 0 + assert entries["wildcard"].flags & BanFlag.SUFFIX + assert entries["wildcard"].flags & BanFlag.PREFIX + assert "allow.me" in entries and entries["allow.me"].level == 50 + assert entries["allow.me"].flags & BanFlag.PERMIT + assert "example.com" in entries and entries["example.com"].level == 60 + assert entries["example.com"].flags & BanFlag.NEWBIES + assert entries["example.com"].flags & BanFlag.PREFIX + + +def test_ban_prefix_suffix_types(): + bans.clear_all_bans() + bans.add_banned_host("*example.com") + assert bans.is_host_banned("foo.example.com") + assert not bans.is_host_banned("example.org") + + bans.clear_all_bans() + bans.add_banned_host("example.*") + assert bans.is_host_banned("example.net") + assert not bans.is_host_banned("demoexample.net") + + bans.clear_all_bans() + bans.add_banned_host("*malicious*") + assert bans.is_host_banned("verymalicioushost.net") + assert not bans.is_host_banned("innocent.net") + + +def test_newbie_permit_enforcement(): + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + bans.clear_all_bans() + + assert create_account("elder", "pw") + + bans.add_banned_host("blocked.example", flags=BanFlag.NEWBIES) + assert login_with_host("elder", "pw", "blocked.example") is not None + assert login_with_host("fresh", "pw", "blocked.example") is None + session = SessionLocal() + try: + assert ( + session.query(PlayerAccount).filter_by(username="fresh").first() + is None + ) + finally: + session.close() + + bans.clear_all_bans() + bans.add_banned_host("locked.example", flags=BanFlag.ALL) + assert login_with_host("elder", "pw", "locked.example") is None + + bans.add_banned_host("locked.example", flags=BanFlag.PERMIT) + assert login_with_host("elder", "pw", "locked.example") is not None diff --git a/tests/test_json_room_fields.py b/tests/test_json_room_fields.py new file mode 100644 index 00000000..0a51d18d --- /dev/null +++ b/tests/test_json_room_fields.py @@ -0,0 +1,69 @@ +import json + +from mud.loaders.json_loader import load_area_from_json +from mud.registry import area_registry, mob_registry, obj_registry, room_registry + + +def test_json_loader_parses_extended_room_fields(tmp_path): + area_registry.clear() + room_registry.clear() + mob_registry.clear() + obj_registry.clear() + area_payload = { + "area": { + "vnum": 4000, + "name": "Test Fields", + "min_vnum": 4000, + "max_vnum": 4002, + "builders": "ROM", + "credits": "ROM", + "area_flags": 0, + "security": 9, + }, + "rooms": [ + { + "id": 4000, + "name": "Explicit Values", + "description": "", + "sector_type": "inside", + "flags": 0, + "heal_rate": 150, + "mana_rate": 90, + "clan": 7, + "owner": "ROM Council", + "exits": {}, + "extra_descriptions": [], + }, + { + "id": 4001, + "name": "Defaulted Values", + "description": "", + "sector_type": "inside", + "flags": 0, + "exits": {}, + "extra_descriptions": [], + }, + ], + "mobs": [], + "objects": [], + "resets": [], + } + path = tmp_path / "extended_area.json" + path.write_text(json.dumps(area_payload)) + try: + load_area_from_json(str(path)) + explicit = room_registry[4000] + assert explicit.heal_rate == 150 + assert explicit.mana_rate == 90 + assert explicit.clan == 7 + assert explicit.owner == "ROM Council" + defaulted = room_registry[4001] + assert defaulted.heal_rate == 100 + assert defaulted.mana_rate == 100 + assert defaulted.clan == 0 + assert defaulted.owner == "" + finally: + area_registry.clear() + room_registry.clear() + mob_registry.clear() + obj_registry.clear() diff --git a/tests/test_skills.py b/tests/test_skills.py index 5e243b2c..19e17222 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -4,6 +4,7 @@ import pytest from mud.models.character import Character +from mud.models.constants import AffectFlag from mud.skills import SkillRegistry from mud.utils import rng_mm @@ -108,3 +109,50 @@ def test_skill_failure_grants_learning_xp(monkeypatch: pytest.MonkeyPatch) -> No assert caster.skills["fireball"] == 52 assert caster.exp == 8 assert any("learn from your mistakes" in msg for msg in caster.messages) + + +def test_skill_use_sets_wait_state_and_blocks_until_ready( + monkeypatch: pytest.MonkeyPatch, +) -> None: + reg = load_registry() + skill = reg.get("acid blast") + monkeypatch.setattr(rng_mm, "number_percent", lambda: 1) + + caster = Character(mana=40, is_npc=False, skills={"acid blast": 100}) + target = Character() + + result = reg.use(caster, "acid blast", target) + assert result == 42 + assert caster.wait == skill.lag + assert caster.mana == 20 + assert caster.cooldowns.get("acid blast", 0) == skill.cooldown + + with pytest.raises(ValueError) as excinfo: + reg.use(caster, "acid blast", target) + assert "recover" in str(excinfo.value) + assert caster.messages[-1] == "You are still recovering." + assert caster.mana == 20 + + +def test_skill_wait_adjusts_for_haste_and_slow(monkeypatch: pytest.MonkeyPatch) -> None: + reg = load_registry() + skill = reg.get("acid blast") + monkeypatch.setattr(rng_mm, "number_percent", lambda: 1) + + haste_caster = Character( + mana=20, + is_npc=False, + affected_by=int(AffectFlag.HASTE), + skills={"acid blast": 100}, + ) + reg.use(haste_caster, "acid blast") + assert haste_caster.wait == max(1, skill.lag // 2) + + slow_caster = Character( + mana=20, + is_npc=False, + affected_by=int(AffectFlag.SLOW), + skills={"acid blast": 100}, + ) + reg.use(slow_caster, "acid blast") + assert slow_caster.wait == skill.lag * 2 diff --git a/tests/test_spawning.py b/tests/test_spawning.py index f50d883e..9b059973 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -3,7 +3,9 @@ from mud.spawning.reset_handler import reset_tick, RESET_TICKS from mud.models.room_json import ResetJson from mud.spawning.mob_spawner import spawn_mob +from mud.spawning.obj_spawner import spawn_object from mud.spawning.templates import MobInstance +from mud.models.constants import ITEM_INVENTORY def test_resets_populate_world(): @@ -178,27 +180,54 @@ def test_reset_P_skips_when_players_present(): assert getattr(key_proto, 'count', 0) == 0 -def test_reset_GE_limits_and_shopkeeper_inventory_flag(): - room_registry.clear(); area_registry.clear(); mob_registry.clear(); obj_registry.clear() - initialize_world('area/area.lst') - room = room_registry[3001] - area = room.area; assert area is not None - # Narrow to controlled resets only - area.resets = [] - # Spawn a shopkeeper (3000) in room 3001 - area.resets.append(ResetJson(command='M', arg1=3000, arg2=1, arg3=room.vnum, arg4=1)) - # Give two copies of lantern (3031) but limit to 1 - area.resets.append(ResetJson(command='G', arg1=3031, arg2=1)) - area.resets.append(ResetJson(command='G', arg1=3031, arg2=1)) +def test_reset_GE_limits_and_shopkeeper_inventory_flag(monkeypatch): from mud.spawning.reset_handler import apply_resets + from mud.utils import rng_mm + + def setup_shop_area(): + room_registry.clear(); area_registry.clear(); mob_registry.clear(); obj_registry.clear() + initialize_world('area/area.lst') + room = room_registry[3001] + area = room.area; assert area is not None + area.resets = [] + room.people = [p for p in room.people if not isinstance(p, MobInstance)] + room.contents.clear() + area.resets.append(ResetJson(command='M', arg1=3000, arg2=1, arg3=room.vnum, arg4=1)) + area.resets.append(ResetJson(command='G', arg1=3031, arg2=1)) + area.resets.append(ResetJson(command='G', arg1=3031, arg2=1)) + return area, room + + # When the global prototype count hits the limit, reroll failure skips the spawn. + area, room = setup_shop_area() + lantern_proto = obj_registry.get(3031) + assert lantern_proto is not None + existing = spawn_object(3031) + assert existing is not None + room.contents.append(existing) + monkeypatch.setattr(rng_mm, 'number_range', lambda a, b: 1) + apply_resets(area) + keeper = next((p for p in room.people if getattr(getattr(p, 'prototype', None), 'vnum', None) == 3000), None) + assert keeper is not None + inv = [getattr(o.prototype, 'vnum', None) for o in getattr(keeper, 'inventory', [])] + assert inv.count(3031) == 0 + assert getattr(lantern_proto, 'count', 0) == 1 + + # A successful 1-in-5 reroll should allow the spawn despite the limit. + area, room = setup_shop_area() + lantern_proto = obj_registry.get(3031) + assert lantern_proto is not None + existing = spawn_object(3031) + assert existing is not None + room.contents.append(existing) + monkeypatch.setattr(rng_mm, 'number_range', lambda a, b: 0) apply_resets(area) keeper = next((p for p in room.people if getattr(getattr(p, 'prototype', None), 'vnum', None) == 3000), None) assert keeper is not None inv = [getattr(o.prototype, 'vnum', None) for o in getattr(keeper, 'inventory', [])] assert inv.count(3031) == 1 - # The inventory copy should be flagged as ITEM_INVENTORY (1<<13) on prototype item = next(o for o in keeper.inventory if getattr(o.prototype, 'vnum', None) == 3031) - assert getattr(item.prototype, 'extra_flags', 0) & (1 << 13) + assert getattr(item.prototype, 'extra_flags', 0) & int(ITEM_INVENTORY) + assert getattr(item, 'extra_flags', 0) & int(ITEM_INVENTORY) def test_reset_mob_limits():