diff --git a/PYTHON_PORT_PLAN.md b/PYTHON_PORT_PLAN.md index 67eed969..6aadd1a0 100644 --- a/PYTHON_PORT_PLAN.md +++ b/PYTHON_PORT_PLAN.md @@ -48,15 +48,7 @@ This document outlines the steps needed to port the remaining ROM 2.4 QuickMUD C ## Next Actions (Aggregated P0s) -- resets: [P0] Reinstate ROM 'P' reset gating, container limits, and prototype counts -- resets: [P0] Mirror ROM 'O' reset gating for duplicates and active players -- resets: [P0] Apply ROM object limits and 1-in-5 reroll for 'G'/'E' resets -- movement_encumbrance: [P0] Enforce closed and no-pass exit gating before moving -- movement_encumbrance: [P0] Block entry to private and guild rooms without access -- security_auth_bans: [P0] Implement ROM ban flag matching (prefix/suffix and BAN_NEWBIES/BAN_PERMIT) -- security_auth_bans: [P0] Persist ban flags and immortal level in ROM format -- skills_spells: [P0] Restore ROM practice trainer gating, INT-based gains, adept caps, and known-skill checks -- skills_spells: [P0] Port check_improve-style skill advancement and XP rewards on use +- 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 @@ -484,67 +476,88 @@ RECENT COMPLETION (2025-09-16): ### skills_spells — Parity Audit 2025-09-17 -STATUS: completion:❌ implementation:partial correctness:fails (confidence 0.58) +STATUS: completion:❌ implementation:partial correctness:fails (confidence 0.60) KEY RISKS: RNG, flags, lag_wait TASKS: -- [P0] Restore ROM practice trainer gating, INT-based gains, adept caps, and known-skill checks — acceptance: practicing without an awake ACT_PRACTICE trainer or with an unknown/zero-rated skill fails, learned% advances by `int_app.learn / rating` toward the class adept cap, and practice sessions decrement exactly once per attempt. +- ✅ [P0] Restore ROM practice trainer gating, INT-based gains, adept caps, and known-skill checks — done 2025-09-17 + EVIDENCE: C src/act_info.c:2680-2760 + EVIDENCE: PY mud/commands/advancement.py:L66-L99 + EVIDENCE: PY mud/models/character.py:L127-L174 + EVIDENCE: PY mud/models/mob.py:L86-L110 + EVIDENCE: PY mud/spawning/templates.py:L35-L75 + EVIDENCE: TEST tests/test_advancement.py::test_practice_requires_trainer_and_caps + EVIDENCE: TEST tests/test_advancement.py::test_practice_applies_int_based_gain + EVIDENCE: TEST tests/test_advancement.py::test_practice_rejects_unknown_skill RATIONALE: ROM `do_practice` scans the room for an ACT_PRACTICE mobile, verifies the skill is trainable for the class, clamps learned% to the class adept cap, and scales gains by the caster's INT learn rate; the port lets players practice anywhere for a flat +25 up to 75% with no trainer or adept enforcement. - FILES: mud/commands/advancement.py; mud/models/constants.py; mud/models/character.py; mud/models/mob.py + FILES: mud/commands/advancement.py; mud/models/character.py; mud/models/mob.py TESTS: tests/test_advancement.py::test_practice_requires_trainer_and_caps; tests/test_advancement.py::test_practice_applies_int_based_gain; tests/test_advancement.py::test_practice_rejects_unknown_skill - REFERENCES: C src/act_info.c:2680-2792; C src/merc.h:738-755; PY mud/commands/advancement.py:5-35; PY mud/models/mob.py:17-58; PY mud/models/character.py:60-128 + REFERENCES: C src/act_info.c:2680-2759; C src/merc.h:539-559; C src/const.c:759-783; PY mud/commands/advancement.py:5-19; PY mud/models/mob.py:17-76; PY mud/models/character.py:58-108 ESTIMATE: M; RISK: medium -- [P0] Port check_improve-style skill advancement and XP rewards on use — acceptance: `SkillRegistry.use` performs ROM success/failure rolls, mutates `caster.skills[name]` toward adept on both outcomes, persists `Character.practice`/`learned` state, and awards experience via gain_exp helpers mirroring `check_improve`. +- ✅ [P0] Port check_improve-style skill advancement and XP rewards on use — done 2025-09-17 + EVIDENCE: C src/skills.c:923-960 + EVIDENCE: PY mud/skills/registry.py:L38-L114 + EVIDENCE: PY mud/models/skill.py:L13-L34 + EVIDENCE: PY mud/models/skill_json.py:L12-L21 + EVIDENCE: PY mud/models/character.py:L144-L169 + EVIDENCE: TEST tests/test_skills.py::test_skill_use_advances_learned_percent + EVIDENCE: TEST tests/test_skills.py::test_skill_failure_grants_learning_xp RATIONALE: `check_improve` uses INT-weighted rolls to raise learned% and grant XP whether the skill succeeds or fails; the Python registry never updates `caster.skills` or XP so abilities never improve with use. FILES: mud/skills/registry.py; mud/models/character.py; mud/advancement.py TESTS: tests/test_skills.py::test_skill_use_advances_learned_percent; tests/test_skills.py::test_skill_failure_grants_learning_xp - REFERENCES: C src/skills.c:923-971; C src/magic.c:547-564; PY mud/skills/registry.py:37-110; PY mud/advancement.py:37-72; PY mud/models/character.py:60-140 + 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.lag`, 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 `do_cast`/skill handlers so abilities impose recovery time; the port ignores `Skill.lag` so actions are spammable. +- [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:547-564; PY mud/skills/registry.py:37-110; PY mud/models/character.py:60-140; PY mud/models/constants.py:158-210 + 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 ESTIMATE: M; RISK: medium NOTES: -- C: src/act_info.c:2680-2792 requires an awake ACT_PRACTICE trainer, decrements practice sessions, and caps learned% at class adept with INT-based gains. -- C: src/skills.c:923-971 together with src/magic.c:547-564 drive `check_improve`, XP rewards, and WAIT_STATE beats whenever skills fire. -- PY: mud/commands/advancement.py:5-35 allows practicing anywhere with constant +25 gains, no adept cap, and no trainer or class gating. -- PY: mud/skills/registry.py:37-110 spends mana and sets cooldowns but never mutates learned%, wait timers, or XP. -- PY: mud/models/character.py:60-140 exposes practice counts, learned maps, wait/daze fields, and mud/advancement.py:37-72 exposes gain_exp helpers that the current flows ignore. +- 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. - Applied tiny fix: none -### movement_encumbrance — Parity Audit 2025-09-16 +### movement_encumbrance — Parity Audit 2025-09-17 -STATUS: completion:❌ implementation:partial correctness:fails (confidence 0.59) +STATUS: completion:❌ implementation:partial correctness:fails (confidence 0.56) KEY RISKS: flags, side_effects TASKS: -- [P0] Enforce closed and no-pass exit gating before moving — acceptance: move_character reads Exit.exit_info and blocks EX_CLOSED/EX_NOPASS exits unless the character has pass door or immortal trust, matching ROM messaging. +- ✅ [P0] Enforce closed and no-pass exit gating before moving — done 2025-09-17 + EVIDENCE: C src/act_move.c:64-113 (closed door and pass door gating) + EVIDENCE: PY mud/world/movement.py:L72-L151 (_exit_block_message and trust gating enforce EX_CLOSED/EX_NOPASS) + EVIDENCE: TEST tests/test_movement_doors.py::test_closed_door_blocks_movement; tests/test_movement_doors.py::test_nopass_blocks_pass_door RATIONALE: `move_char` checks exit flags and respects AFF_PASS_DOOR and EX_NOPASS; the port ignores exit_info so closed doors and nopass exits are always traversable. - FILES: mud/world/movement.py; mud/models/constants.py; mud/models/room.py; mud/models/character.py + FILES: mud/world/movement.py; mud/models/room.py; mud/models/constants.py; mud/models/character.py TESTS: tests/test_movement_doors.py::test_closed_door_blocks_movement - REFERENCES: C src/act_move.c:68-113; C src/merc.h:1288-1310; PY mud/world/movement.py:62-135; PY mud/models/room.py:30-76; PY mud/models/constants.py:460-476; PY mud/models/character.py:90-116 + REFERENCES: C src/act_move.c:64-113; C src/merc.h:1290-1310; PY mud/world/movement.py:5-92; PY mud/models/room.py:20-60; PY mud/models/constants.py:440-470; PY mud/models/character.py:60-120 ESTIMATE: M; RISK: medium -- [P0] Block entry to private and guild rooms without access — acceptance: move_character denies entry to ROOM_PRIVATE/ROOM_SOLITARY and foreign guild rooms unless the character is owner/trusted, mirroring `room_is_private` and class guild tables. +- ✅ [P0] Block entry to private and guild rooms without access — done 2025-09-17 + EVIDENCE: C src/act_move.c:113-151 (room owner/guild gating); C src/handler.c:2553-2583 (room_is_private) + EVIDENCE: C src/const.c:394-419 (class_table guild vnums) + EVIDENCE: PY mud/models/constants.py:L113-L121 (CLASS_GUILD_ROOMS mapping) + EVIDENCE: PY mud/world/movement.py:L94-L160 (room ownership checks and guild gating) + EVIDENCE: TEST tests/test_movement_privacy.py::test_private_room_blocks_entry; tests/test_movement_privacy.py::test_guild_room_rejects_other_classes RATIONALE: ROM prevents entry to clan/guild rooms and private spaces via `room_is_private` and class guild arrays; the port never checks room_flags or clan ownership so restricted rooms are freely accessible. FILES: mud/world/movement.py; mud/models/room.py; mud/models/constants.py; mud/models/character.py TESTS: tests/test_movement_privacy.py::test_private_room_blocks_entry - REFERENCES: C src/act_move.c:113-151; C src/handler.c:2564-2583; PY mud/world/movement.py:62-135; PY mud/models/room.py:30-76; PY mud/models/constants.py:139-155; PY mud/models/character.py:90-116 + REFERENCES: C src/act_move.c:113-151; C src/handler.c:2564-2583; PY mud/world/movement.py:5-92; PY mud/models/room.py:20-76; PY mud/models/constants.py:120-150; PY mud/models/character.py:60-120 ESTIMATE: M; RISK: medium NOTES: -- C: src/act_move.c:68-151 guards closed exits, pass door, charm loyalty, guild rooms, and trust checks before movement proceeds. +- C: src/act_move.c:64-151 guards closed exits, pass door, charm loyalty, guild rooms, and trust checks before movement proceeds. - C: src/handler.c:2564-2583 defines `room_is_private`, blocking ROOM_PRIVATE/ROOM_SOLITARY and owner-protected rooms unless trusted. -- PY: mud/world/movement.py:62-135 ignores exit_info, room_flags, owner, and guild restrictions so closed and private rooms never block movement. -- PY: mud/models/constants.py:139-155 and 460-476 expose ROOM_PRIVATE/ROOM_SOLITARY and EX_NOPASS bits that move_character never inspects. +- PY: mud/world/movement.py:5-92 ignores exit flags, owner, and guild restrictions so closed and private rooms never block movement. +- PY: mud/models/constants.py:120-150 and 440-470 expose ROOM_PRIVATE/ROOM_SOLITARY and EX_NOPASS bits that the movement code never consults. +- Applied tiny fix: none @@ -582,7 +595,7 @@ TASKS: ### resets — Parity Audit 2025-09-17 -STATUS: completion:❌ implementation:partial correctness:fails (confidence 0.54) +STATUS: completion:❌ implementation:partial correctness:fails (confidence 0.55) KEY RISKS: file_formats, flags, side_effects TASKS: @@ -596,39 +609,38 @@ TASKS: - ✅ [P0] Enforce mob reset limits when applying resets — done 2025-09-16 EVIDENCE: C src/db.c:1691-1752 (reset_room enforces global count and per-room limit for 'M') - EVIDENCE: PY mud/spawning/reset_handler.py:78-170 (tracks prototype counts and skips when arg2/arg4 caps reached) + EVIDENCE: PY mud/spawning/reset_handler.py:81-135 (tracks prototype counts and skips when arg2/arg4 caps reached) EVIDENCE: DOC doc/area.txt:466-469 (documents mob limit semantics) EVIDENCE: ARE area/midgaard.are:6085-6094 (Midgaard wizard reset using arg4 cap) FILES: mud/spawning/reset_handler.py TESTS: pytest -q tests/test_spawning.py::test_reset_mob_limits -- [P0] Reinstate ROM 'P' reset gating, container limits, and prototype counts — acceptance: 'P' resets abort when `area.nplayer > 0`, stop once `pObjIndex->count` meets the coerced limit from arg2 (including the legacy -1/0 no-limit semantics), reuse the latest container instance, and increment both container and content prototype counts exactly as `reset_room()` does. - RATIONALE: ROM checks `pRoom->area->nplayer`, tracks `LastObj`, and compares `OBJ_INDEX_DATA->count` before `obj_to_obj`; the port never increments prototype counts for 'P' or enforces area gating, so desks like Midgaard's duplicate loot endlessly even with players present. - FILES: mud/spawning/reset_handler.py; mud/spawning/obj_spawner.py; mud/models/object.py - TESTS: tests/test_spawning.py::test_reset_P_limit_enforced; tests/test_spawning.py::test_reset_P_skips_when_players_present - REFERENCES: C src/db.c:1788-1835; C src/db.c:1053-1096; PY mud/spawning/reset_handler.py:205-282; PY mud/spawning/obj_spawner.py:8-18; PY mud/models/object.py:11-56; DOC doc/area.txt:478-483; ARE area/midgaard.are:6365-6368 - ESTIMATE: M; RISK: medium +- ✅ [P0] Reinstate ROM 'P' reset gating, container limits, and prototype counts — done 2025-09-17 + EVIDENCE: C src/db.c:1788-1848 (reset_room 'P' enforces area->nplayer gating, prototype counts, and arg2 limits) + EVIDENCE: DOC doc/area.txt:470-488 (P reset semantics covering player gating and limit coercion) + EVIDENCE: ARE area/midgaard.are:6366-6368 (Captain's desk/safe P resets with limits) + EVIDENCE: PY mud/spawning/reset_handler.py:120-236 (area.nplayer guard, container reuse, global limit enforcement) + EVIDENCE: PY mud/spawning/obj_spawner.py:8-17 (increment ObjIndex.count on spawn) + FILES: mud/spawning/reset_handler.py; mud/spawning/obj_spawner.py + TESTS: pytest -q tests/test_spawning.py::test_reset_P_limit_enforced; pytest -q tests/test_spawning.py::test_reset_P_skips_when_players_present -- [P0] Mirror ROM 'O' reset gating for duplicates and active players — acceptance: `apply_resets` skips 'O' placements when the room already holds the vnum or when `area.nplayer > 0`, matching donation pit behaviour and preserving prototype counts. - RATIONALE: ROM scans room contents and `area.nplayer` before spawning 'O' objects; the port drops duplicates even while players are present because it never checks room contents or `area.nplayer`. - FILES: mud/spawning/reset_handler.py; mud/registry.py - TESTS: tests/test_spawning.py::test_resets_room_duplication_and_player_presence - REFERENCES: C src/db.c:1754-1786; PY mud/spawning/reset_handler.py:118-205; PY mud/spawning/reset_handler.py:289-320; DOC doc/area.txt:473-476; ARE area/midgaard.are:6087-6094 - ESTIMATE: M; RISK: medium +- ✅ [P0] Mirror ROM 'O' reset gating for duplicates and active players — done 2025-09-17 + EVIDENCE: C src/db.c:1760-1797 (reset_room 'O' skips when area->nplayer > 0 or object already present) + EVIDENCE: DOC doc/area.txt:470-478 (object reset duplication and player gating rules) + EVIDENCE: ARE area/midgaard.are:6085-6094 (donation pit O resets depending on room occupancy) + EVIDENCE: PY mud/spawning/reset_handler.py:96-149 (room duplicate checks and area.nplayer guard for O resets) + 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 respect prototype count limits, roll `number_range(0,4)` when limits are met, maintain LastObj/LastMob semantics, and update ITEM_INVENTORY for shopkeepers exactly like ROM. - RATIONALE: `reset_room` only equips objects if proto.count is below the limit or a 1-in-5 reroll fires; the port merely counts items on `LastMob`, so world counts never advance and caps never trigger. - 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:1838-1960; PY mud/spawning/reset_handler.py:178-233; PY mud/spawning/obj_spawner.py:8-18; DOC doc/area.txt:485-490; ARE area/midgaard.are:6088-6196 +- ✅ [P0] Apply ROM object limits and 1-in-5 reroll for 'G'/'E' resets — done 2025-09-18 + EVIDENCE: C src/db.c:1862-1980; PY mud/spawning/reset_handler.py:197-299; TEST tests/test_spawning.py::test_reset_GE_limits_and_shopkeeper_inventory_flag; tests/test_spawning.py::test_reset_G_reroll_allows_extra_copy + 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 NOTES: -- C: src/db.c:1669-1960 `reset_room` enforces area.nplayer gating, prototype counts, LastObj reuse, and 1-in-5 rerolls for O/P/G/E. -- PY: mud/spawning/reset_handler.py:118-320 spawns O/P/G/E without area.nplayer checks, prototype count tracking, or rerolls so duplicates pile up. -- PY: mud/spawning/obj_spawner.py:8-18 instantiates objects but never increments `ObjIndex.count`, preventing global limits from triggering. -- DOC: doc/area.txt:395-490 documents ROM reset syntax, container caps, and reroll behaviour. -- ARE: area/midgaard.are:6085-6368 covers donation pits, desk/safe chains, and shopkeeper inventory relying on these guards. +- 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. +- 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 @@ -636,29 +648,26 @@ NOTES: ### security_auth_bans — Parity Audit 2025-09-17 -STATUS: completion:❌ implementation:partial correctness:fails (confidence 0.53) +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:104-205; C src/ban.c:235-352; PY mud/security/bans.py:12-140; PY mud/account/account_service.py:20-78; PY mud/net/connection.py:8-120 +- ✅ [P0] Implement ROM ban flag matching (prefix/suffix and BAN_NEWBIES/BAN_PERMIT) — done 2025-09-18 + EVIDENCE: C src/ban.c:40-200; PY mud/security/bans.py:1-185; PY mud/account/account_service.py:52-64; PY mud/net/connection.py:25-63; TEST 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] 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:43-101; C src/ban.c:140-235; PY mud/security/bans.py:52-150 + 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 NOTES: -- C: src/ban.c:43-352 persists ban entries with flag letters, immortal level, BAN_PREFIX/BAN_SUFFIX matching, and BAN_NEWBIES/BAN_PERMIT gating inside `check_ban` and `ban_site`. -- PY: mud/security/bans.py:12-150 stores lowercase host strings with constant `DF` flags and no flag-specific enforcement or persistence. -- PY: mud/account/account_service.py:20-78 and mud/net/connection.py:8-120 lack BAN_NEWBIES/BAN_PERMIT handling, so host bans either over-trigger or fail entirely. -- DOC: doc/security.txt:13-33 documents the immortal-facing ban command usage and expectations for logging/security. +- 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. +- 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 @@ -1057,3 +1066,18 @@ NOTES: - acceptance_criteria: pytest runs with no DeprecationWarning for datetime.utcnow() + + diff --git a/mud/account/account_service.py b/mud/account/account_service.py index ac7ab75b..c24c19be 100644 --- a/mud/account/account_service.py +++ b/mud/account/account_service.py @@ -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]: diff --git a/mud/commands/advancement.py b/mud/commands/advancement.py index a02cd38d..33e8192e 100644 --- a/mud/commands/advancement.py +++ b/mud/commands/advancement.py @@ -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}." diff --git a/mud/models/__init__.py b/mud/models/__init__.py index d1e4fd78..9e99fc1f 100644 --- a/mud/models/__init__.py +++ b/mud/models/__init__.py @@ -17,10 +17,12 @@ Direction, Sector, Position, + Stat, WearLocation, Sex, Size, ItemType, + ActFlag, ) from .area_json import AreaJson, VnumRangeJson @@ -99,8 +101,10 @@ "Direction", "Sector", "Position", + "Stat", "WearLocation", "Sex", "Size", "ItemType", + "ActFlag", ] diff --git a/mud/models/character.py b/mud/models/character.py index b4d1350e..34d736b6 100644 --- a/mud/models/character.py +++ b/mud/models/character.py @@ -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 @@ -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 @@ -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 + diff --git a/mud/models/constants.py b/mud/models/constants.py index f069472f..2bc3f49b 100644 --- a/mud/models/constants.py +++ b/mud/models/constants.py @@ -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 @@ -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""" @@ -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""" @@ -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: diff --git a/mud/models/mob.py b/mud/models/mob.py index 8f7543ba..ec1f6c79 100644 --- a/mud/models/mob.py +++ b/mud/models/mob.py @@ -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""" @@ -81,5 +83,31 @@ class MobIndex: def __repr__(self) -> str: return f"" + 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] = {} diff --git a/mud/models/skill.py b/mud/models/skill.py index 650a3682..8ec03259 100644 --- a/mud/models/skill.py +++ b/mud/models/skill.py @@ -19,7 +19,16 @@ class Skill: cooldown: int = 0 failure_rate: float = 0.0 messages: Dict[str, str] = field(default_factory=dict) + rating: Dict[int, int] = field(default_factory=dict) @classmethod def from_json(cls, data: SkillJson) -> "Skill": - return cls(**data.to_dict()) + payload = data.to_dict() + raw_rating = payload.pop("rating", {}) or {} + converted_rating: Dict[int, int] = {} + for key, value in raw_rating.items(): + try: + converted_rating[int(key)] = int(value) + except (TypeError, ValueError): + continue + return cls(rating=converted_rating, **payload) diff --git a/mud/models/skill_json.py b/mud/models/skill_json.py index c87e8e97..c5c98b13 100644 --- a/mud/models/skill_json.py +++ b/mud/models/skill_json.py @@ -18,3 +18,4 @@ class SkillJson(JsonDataclass): cooldown: int = 0 failure_rate: float = 0.0 messages: dict[str, str] = field(default_factory=dict) + rating: dict[str, int] = field(default_factory=dict) diff --git a/mud/net/connection.py b/mud/net/connection.py index 0cde20db..1ef9ee15 100644 --- a/mud/net/connection.py +++ b/mud/net/connection.py @@ -9,6 +9,7 @@ list_characters, create_character, ) +from mud.security import bans from mud.commands import process_command from mud.net.session import Session, SESSIONS from mud.net.protocol import send_to_char @@ -21,6 +22,10 @@ async def handle_connection( host_for_ban = None if isinstance(addr, tuple) and addr: host_for_ban = addr[0] + if host_for_ban and bans.is_host_banned(host_for_ban, bans.BanFlag.ALL): + writer.write(b"Your site has been banned from this mud.\r\n") + await writer.drain() + return session = None char = None account = None @@ -47,6 +52,10 @@ async def handle_connection( # Enforce site/account bans at login time account = login_with_host(username, password, host_for_ban) if not account: + if host_for_ban and bans.is_host_banned(host_for_ban, bans.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..f2d0979f 100644 --- a/mud/security/bans.py +++ b/mud/security/bans.py @@ -1,38 +1,113 @@ -"""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 ban registry supporting prefix/suffix and flag semantics.""" 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 List, Set + + +class BanFlag(IntFlag): + """Bit flags mirroring ROM's BAN_* letter mapping (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: + pattern: str + flags: BanFlag + level: int = 0 + + def matches(self, host: str) -> bool: + host_l = host.lower() + if not self.pattern: + return False + prefix = bool(self.flags & BanFlag.PREFIX) + suffix = bool(self.flags & BanFlag.SUFFIX) + if prefix and suffix: + return self.pattern in host_l + if prefix: + return host_l.endswith(self.pattern) + if suffix: + return host_l.startswith(self.pattern) + return host_l == self.pattern + + +_ban_entries: List[BanEntry] = [] _banned_accounts: Set[str] = set() # Default storage location, mirroring ROM's BAN_FILE semantics. BANS_FILE = Path("data/bans.txt") +_DEFAULT_ENTRY_FLAGS = BanFlag.ALL | BanFlag.PERMANENT + + +def _parse_host_pattern(raw: str | None) -> tuple[str, BanFlag]: + text = (raw or "").strip().lower() + flags = BanFlag(0) + if text.startswith("*"): + flags |= BanFlag.PREFIX + text = text[1:] + if text.endswith("*"): + flags |= BanFlag.SUFFIX + text = text[:-1] + return text, flags + 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 add_banned_host(host: str, *, flags: BanFlag | None = None, level: int = 0) -> None: + pattern, pattern_flags = _parse_host_pattern(host) + if not pattern: + return + entry_flags = (flags | BanFlag.PERMANENT) if flags is not None else _DEFAULT_ENTRY_FLAGS + entry_flags |= pattern_flags + _ban_entries[:] = [ + entry + for entry in _ban_entries + if not (entry.pattern == pattern and entry.flags == entry_flags) + ] + _ban_entries.append(BanEntry(pattern=pattern, flags=entry_flags, level=level)) def remove_banned_host(host: str) -> None: - _banned_hosts.discard(host.strip().lower()) + pattern, _ = _parse_host_pattern(host) + if not pattern: + return + _ban_entries[:] = [entry for entry in _ban_entries if entry.pattern != pattern] -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 + host_norm = host.strip().lower() + for entry in _ban_entries: + if not entry.flags & ban_type: + continue + if entry.matches(host_norm): + return True + return False def add_banned_account(username: str) -> None: @@ -49,29 +124,25 @@ 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: + return "".join(letter for flag, letter in _FLAG_TO_LETTER.items() if flags & flag) -def _flags_to_string() -> str: - # For now, we only persist permanent, all-site bans. - return _ROM_FLAG_ALL + _ROM_FLAG_PERM +def _string_to_flags(text: str) -> BanFlag: + flags = BanFlag(0) + for letter in text: + flag = _LETTER_TO_FLAG.get(letter.upper()) + if flag: + flags |= flag + return flags def save_bans_file(path: Path | str | None = None) -> None: - """Write permanent site bans to file in ROM format. + """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. + entries = [entry for entry in _ban_entries if entry.flags & BanFlag.PERMANENT] + if not entries: try: if target.exists(): target.unlink() @@ -80,15 +151,14 @@ 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 sorted(entries, key=lambda e: e.pattern): + 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 +168,18 @@ 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() + level = 0 + try: + level = int(parts[1]) + except ValueError: + pass + flags = _string_to_flags(parts[2]) + if not pattern: + continue + _ban_entries.append(BanEntry(pattern=pattern, flags=flags, level=level)) + count += 1 return count diff --git a/mud/skills/registry.py b/mud/skills/registry.py index 14d49a01..6443a4ce 100644 --- a/mud/skills/registry.py +++ b/mud/skills/registry.py @@ -6,6 +6,7 @@ from random import Random from typing import Callable, Dict, Optional +from mud.advancement import gain_exp from mud.models import Skill, SkillJson from mud.utils import rng_mm from mud.models.json_io import dataclass_from_dict @@ -35,13 +36,8 @@ def get(self, name: str) -> Skill: return self.skills[name] def use(self, caster, name: str, target=None): - """Execute a skill and handle resource costs and failure. + """Execute a skill and handle ROM-style success, lag, and advancement.""" - Parity: If the caster has a learned percentage for this skill in - `caster.skills[name]` (0..100), success is determined by a ROM-style - percent roll (number_percent) against that learned value. If no - learned value is present, fall back to `failure_rate` as before. - """ skill = self.get(name) if caster.mana < skill.mana_cost: raise ValueError("not enough mana") @@ -51,35 +47,72 @@ def use(self, caster, name: str, target=None): raise ValueError("skill on cooldown") caster.mana -= skill.mana_cost - # ROM parity: prefer per-character learned% when available - learned = None + + learned: Optional[int] try: - learned = caster.skills.get(name) # 0..100 + learned_val = caster.skills.get(name) + learned = int(learned_val) if learned_val is not None else None except Exception: - # Characters without a `skills` mapping should not error learned = None + roll = rng_mm.number_percent() + success: bool if learned is not None: - # Success when roll <= learned (ROM practice mechanics) - if rng_mm.number_percent() > int(learned): - cooldowns[name] = skill.cooldown - caster.cooldowns = cooldowns - return False + success = roll <= learned else: - # Fallback: use failure_rate gate (legacy behavior) - # Convert float failure_rate (0.0..1.0) to percentage threshold 0..100 failure_threshold = int(round(skill.failure_rate * 100)) - if rng_mm.number_percent() <= failure_threshold: - cooldowns[name] = skill.cooldown - caster.cooldowns = cooldowns - return False - # Success path (roll > threshold): execute handler + success = roll > failure_threshold + + result = False + if success: + result = self.handlers[name](caster, target) - result = self.handlers[name](caster, target) cooldowns[name] = skill.cooldown caster.cooldowns = cooldowns + + self._check_improve(caster, skill, name, success) return result + def _check_improve(self, caster, skill: Skill, name: str, success: bool) -> None: + from mud.models.character import Character # Local import to avoid cycle + + if not isinstance(caster, Character): + return + if caster.is_npc: + return + learned = caster.skills.get(name) + if learned is None or learned <= 0: + return + adept = caster.skill_adept_cap() + if learned >= adept: + return + rating = skill.rating.get(caster.ch_class, 1) + if rating <= 0: + return + + chance = 10 * caster.get_int_learn_rate() + multiplier = 1 + chance //= max(1, multiplier * rating * 4) + chance += caster.level + if rng_mm.number_range(1, 1000) > chance: + return + + if success: + improve_chance = max(5, min(95, 100 - learned)) + if rng_mm.number_percent() < improve_chance: + caster.skills[name] = min(adept, learned + 1) + caster.messages.append(f"You have become better at {skill.name}!") + gain_exp(caster, 2 * rating) + else: + improve_chance = max(5, min(30, learned // 2)) + if rng_mm.number_percent() < improve_chance: + increment = rng_mm.number_range(1, 3) + caster.skills[name] = min(adept, learned + increment) + caster.messages.append( + f"You learn from your mistakes, and your {skill.name} skill improves." + ) + gain_exp(caster, 2 * rating) + def tick(self, character) -> None: """Reduce active cooldowns on a character by one tick.""" cooldowns = getattr(character, "cooldowns", {}) diff --git a/mud/spawning/obj_spawner.py b/mud/spawning/obj_spawner.py index 65fac87b..aebf95d9 100644 --- a/mud/spawning/obj_spawner.py +++ b/mud/spawning/obj_spawner.py @@ -15,4 +15,6 @@ def spawn_object(vnum: int) -> Optional[Object]: inst.value = list(getattr(proto, 'value', [0, 0, 0, 0, 0])) except Exception: inst.value = [0, 0, 0, 0, 0] + if hasattr(proto, 'count'): + proto.count = getattr(proto, 'count', 0) + 1 return inst diff --git a/mud/spawning/reset_handler.py b/mud/spawning/reset_handler.py index 7c1f9adb..352a6073 100644 --- a/mud/spawning/reset_handler.py +++ b/mud/spawning/reset_handler.py @@ -1,11 +1,11 @@ from __future__ import annotations import logging -from typing import Dict, List, Optional +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 +from mud.registry import room_registry, area_registry, mob_registry, obj_registry from .mob_spawner import spawn_mob from .obj_spawner import spawn_object from .templates import MobInstance @@ -33,6 +33,37 @@ def _count_existing_mobs() -> Dict[int, int]: return counts +def _gather_object_state() -> Tuple[Dict[int, int], Dict[int, List[object]]]: + """Rebuild OBJ_INDEX_DATA->count and capture instances by prototype vnum.""" + + counts: Dict[int, int] = {} + instances: Dict[int, List[object]] = {} + + def tally(obj: object) -> None: + proto = getattr(obj, "prototype", None) + vnum = getattr(proto, "vnum", None) + if vnum is None: + return + counts[vnum] = counts.get(vnum, 0) + 1 + instances.setdefault(vnum, []).append(obj) + for contained in getattr(obj, "contained_items", []) or []: + tally(contained) + + for room in room_registry.values(): + for obj in getattr(room, "contents", []): + tally(obj) + for mob in getattr(room, "people", []): + if isinstance(mob, MobInstance): + for carried in getattr(mob, "inventory", []): + tally(carried) + + for vnum, proto in obj_registry.items(): + if hasattr(proto, "count"): + proto.count = counts.get(vnum, 0) + + return counts, instances + + def _resolve_reset_limit(raw: Optional[int]) -> int: """Mirror ROM's limit coercion for resets (old format, unlimited markers).""" @@ -80,7 +111,10 @@ def apply_resets(area: Area) -> None: last_mob: Optional[MobInstance] = None last_obj: Optional[object] = None - spawned_objects: Dict[int, List[object]] = {} + _, existing_objects = _gather_object_state() + spawned_objects: Dict[int, List[object]] = { + vnum: list(instances) for vnum, instances in existing_objects.items() + } mob_counts = _count_existing_mobs() for reset in area.resets: @@ -136,26 +170,51 @@ def apply_resets(area: Area) -> None: elif cmd == 'O': obj_vnum = reset.arg1 or 0 room_vnum = reset.arg3 or 0 - obj = spawn_object(obj_vnum) room = room_registry.get(room_vnum) - if obj and room: + if obj_vnum <= 0 or room is None: + logging.warning('Invalid O reset %s -> %s', obj_vnum, room_vnum) + last_obj = None + continue + if getattr(area, 'nplayer', 0) > 0: + last_obj = None + continue + existing_in_room = [ + obj + for obj in getattr(room, 'contents', []) + if getattr(getattr(obj, 'prototype', None), 'vnum', None) == obj_vnum + ] + if existing_in_room: + last_obj = None + continue + obj = spawn_object(obj_vnum) + if obj: room.add_object(obj) last_obj = obj spawned_objects.setdefault(obj_vnum, []).append(obj) else: logging.warning('Invalid O reset %s -> %s', obj_vnum, room_vnum) + last_obj = None elif cmd == 'G': obj_vnum = reset.arg1 or 0 limit = _resolve_reset_limit(reset.arg2) if not last_mob: logging.warning('Invalid G reset %s (no LastMob)', obj_vnum) continue + obj_proto = obj_registry.get(obj_vnum) + if obj_proto is None: + logging.warning('Invalid G reset %s (missing prototype)', obj_vnum) + continue existing = [ o for o in getattr(last_mob, 'inventory', []) if getattr(getattr(o, 'prototype', None), 'vnum', None) == obj_vnum ] - if len(existing) >= limit: + rerolled = False + if getattr(obj_proto, 'count', 0) >= limit: + if rng_mm.number_range(0, 4) != 0: + continue + rerolled = True + if len(existing) >= limit and not rerolled: continue obj = spawn_object(obj_vnum) if obj: @@ -194,12 +253,21 @@ def apply_resets(area: Area) -> None: if not last_mob: logging.warning('Invalid E reset %s (no LastMob)', obj_vnum) continue + obj_proto = obj_registry.get(obj_vnum) + if obj_proto is None: + logging.warning('Invalid E reset %s (missing prototype)', obj_vnum) + continue existing = [ o for o in getattr(last_mob, 'inventory', []) if getattr(getattr(o, 'prototype', None), 'vnum', None) == obj_vnum ] - if len(existing) >= limit: + rerolled = False + if getattr(obj_proto, 'count', 0) >= limit: + if rng_mm.number_range(0, 4) != 0: + continue + rerolled = True + if len(existing) >= limit and not rerolled: continue obj = spawn_object(obj_vnum) if obj: @@ -233,31 +301,71 @@ def apply_resets(area: Area) -> None: elif cmd == 'P': obj_vnum = reset.arg1 or 0 container_vnum = reset.arg3 or 0 - count = max(1, int(reset.arg4 or 1)) - if container_vnum <= 0: + target_count = max(1, int(reset.arg4 or 1)) + limit = _resolve_reset_limit(reset.arg2) + if obj_vnum <= 0 or container_vnum <= 0: logging.warning('Invalid P reset %s -> %s', obj_vnum, container_vnum) + last_obj = None + continue + if getattr(area, 'nplayer', 0) > 0: + last_obj = None + continue + obj_proto = obj_registry.get(obj_vnum) + container_proto = obj_registry.get(container_vnum) + if obj_proto is None or container_proto is 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)) + if remaining_global <= 0: + last_obj = None continue container_obj: Optional[object] = None if last_obj and getattr(getattr(last_obj, 'prototype', None), 'vnum', None) == container_vnum: container_obj = last_obj if not container_obj: - lst = spawned_objects.get(container_vnum) or [] - container_obj = lst[-1] if lst else None + candidates = spawned_objects.get(container_vnum) or [] + for candidate in reversed(candidates): + room = getattr(candidate, 'location', None) + if room is None or room.area is area: + container_obj = candidate + break + if not container_obj: + for room in room_registry.values(): + if room.area is not area: + continue + for obj in getattr(room, 'contents', []): + if getattr(getattr(obj, 'prototype', None), 'vnum', None) == container_vnum: + container_obj = obj + spawned_objects.setdefault(container_vnum, []).append(obj) + break + if container_obj: + break if not container_obj: logging.warning('Invalid P reset %s -> %s (no container instance)', obj_vnum, container_vnum) + last_obj = None continue existing = [ o for o in getattr(container_obj, 'contained_items', []) if getattr(getattr(o, 'prototype', None), 'vnum', None) == obj_vnum ] - to_make = max(0, count - len(existing)) + if len(existing) >= target_count: + last_obj = container_obj + continue + to_make = min(target_count - len(existing), remaining_global) + made = 0 for _ in range(to_make): obj = spawn_object(obj_vnum) if not obj: + logging.warning('Invalid P reset %s', obj_vnum) break getattr(container_obj, 'contained_items').append(obj) spawned_objects.setdefault(obj_vnum, []).append(obj) + made += 1 + remaining_global -= 1 + if remaining_global <= 0: + break try: container_obj.value[1] = container_obj.prototype.value[1] except Exception: diff --git a/mud/spawning/templates.py b/mud/spawning/templates.py index f92118ce..e597d35a 100644 --- a/mud/spawning/templates.py +++ b/mud/spawning/templates.py @@ -12,6 +12,7 @@ from mud.models.obj import ObjIndex from mud.models.object import Object +from mud.models.constants import ActFlag, Position @dataclass class ObjectInstance: @@ -43,6 +44,7 @@ class MobInstance: # Minimal encumbrance fields to interoperate with move_character carry_weight: int = 0 carry_number: int = 0 + position: int = Position.STANDING @classmethod def from_prototype(cls, proto: MobIndex) -> 'MobInstance': @@ -62,3 +64,12 @@ def add_to_inventory(self, obj: Object) -> None: def equip(self, obj: Object, slot: int) -> None: # stub self.add_to_inventory(obj) + + def has_act_flag(self, flag: ActFlag) -> bool: + proto = getattr(self, 'prototype', None) + if proto is None: + return False + checker = getattr(proto, 'has_act_flag', None) + if callable(checker): + return bool(checker(flag)) + return False diff --git a/mud/world/movement.py b/mud/world/movement.py index f052b1df..b7f416ce 100644 --- a/mud/world/movement.py +++ b/mud/world/movement.py @@ -2,7 +2,17 @@ from typing import Dict, Iterable from mud.models.character import Character -from mud.models.constants import Direction, Sector, AffectFlag, ItemType +from mud.models.constants import ( + Direction, + Sector, + AffectFlag, + ItemType, + RoomFlag, + EX_CLOSED, + EX_NOPASS, + CLASS_GUILD_ROOMS, +) +from mud.models.room import Exit, Room from mud.net.protocol import broadcast_room @@ -59,6 +69,65 @@ def can_carry_n(ch: Character) -> int: return MAX_WEAR + 2 * d + ch.level +def _exit_block_message(char: Character, exit: Exit) -> str | None: + """Return ROM-style denial message if a closed exit blocks movement.""" + + exit_info = int(getattr(exit, "exit_info", 0) or 0) + if not exit_info: + return None + + is_closed = bool(exit_info & EX_CLOSED) + if not is_closed: + return None + + has_pass_door = bool(char.affected_by & AffectFlag.PASS_DOOR) + exit_nopass = bool(exit_info & EX_NOPASS) + is_trusted = char.is_admin or char.is_immortal() + + if (has_pass_door and not exit_nopass) or is_trusted: + return None + + keyword = (exit.keyword or "door").strip() or "door" + return f"The {keyword} is closed." + + +def _is_room_owner(char: Character, room: Room) -> bool: + owner = (getattr(room, "owner", None) or "").strip() + if not owner or not char.name: + return False + owner_names = {token.lower() for token in owner.split() if token} + return char.name.lower() in owner_names + + +def _room_is_private(room: Room) -> bool: + if getattr(room, "owner", None): + return True + + occupants = len(getattr(room, "people", []) or []) + flags = int(getattr(room, "room_flags", 0) or 0) + + if flags & int(RoomFlag.ROOM_PRIVATE) and occupants >= 2: + return True + if flags & int(RoomFlag.ROOM_SOLITARY) and occupants >= 1: + return True + if flags & int(RoomFlag.ROOM_IMP_ONLY): + return True + return False + + +def _is_foreign_guild_room(room: Room, ch_class: int) -> bool: + vnum = getattr(room, "vnum", 0) + if not vnum: + return False + + for class_id, guild_vnums in CLASS_GUILD_ROOMS.items(): + if class_id == ch_class: + continue + if any(vnum == guild for guild in guild_vnums if guild): + return True + return False + + def move_character(char: Character, direction: str) -> str: dir_key = direction.lower() if dir_key not in dir_map: @@ -75,6 +144,21 @@ def move_character(char: Character, direction: str) -> str: current_room = char.room target_room = exit.to_room + blocked_msg = _exit_block_message(char, exit) + if blocked_msg: + return blocked_msg + + trusted = char.is_admin or char.is_immortal() + if not trusted and not _is_room_owner(char, target_room) and _room_is_private(target_room): + return "That room is private right now." + + if ( + not char.is_npc + and not trusted + and _is_foreign_guild_room(target_room, char.ch_class) + ): + return "You aren't allowed in there." + # --- Sector-based gating and movement costs (ROM act_move.c) --- from_sector = Sector(current_room.sector_type) to_sector = Sector(target_room.sector_type) diff --git a/port.instructions.md b/port.instructions.md index a8760c6c..ac3b778b 100644 --- a/port.instructions.md +++ b/port.instructions.md @@ -97,6 +97,9 @@ - RULE: Apply ROM reset semantics for 'P' nesting and limits; track `LastObj`/`LastMob` during area resets and respect `arg2` limits and lock-state fix-ups. RATIONALE: Vnum-keyed placement loses instance order and breaks container contents; limit/lock semantics matter for canonical areas. EXAMPLE: after 'O' creates container C (LastObj=C), 'P' places items into C until `count_obj_list` reaches arg4; then `C->value[1] = C->pIndexData->value[1]`. +- RULE: Area resets must skip 'O'/'P'/'G'/'E' placements while `area.nplayer > 0`, reuse `LastObj`/`LastMob`, honour `OBJ_INDEX_DATA->count` limits with the 1-in-5 reroll, and bump prototype counts on spawn. + RATIONALE: Donation pits, desks, and shopkeeper inventories rely on ROM gating; ignoring it floods rooms with duplicates and disables world caps. + EXAMPLE: pytest -q tests/test_spawning.py::test_resets_room_duplication_and_player_presence - RULE: Reset loaders must mirror ROM `load_resets` parsing: ignore `if_flag`, set `arg1..arg4` like C, and keep mob/object limits for 'M'/'P'. RATIONALE: Dropping reset arguments erases ROM spawn caps and duplicates mobs/objects. EXAMPLE: convert_area('midgaard.are') → ResetJson(command='M', arg1=3000, arg2=1, arg3=3033, arg4=1) diff --git a/tests/test_account_auth.py b/tests/test_account_auth.py index bdb071bc..88bbab0c 100644 --- a/tests/test_account_auth.py +++ b/tests/test_account_auth.py @@ -8,12 +8,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(): @@ -50,6 +52,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") @@ -59,6 +62,58 @@ def test_banned_host_cannot_login(): assert login_with_host("carol", "pw", "198.51.100.20") is not None +def test_ban_prefix_suffix_types(): + bans.clear_all_bans() + bans.add_banned_host("*example.com") + bans.add_banned_host("corp.*") + bans.add_banned_host("*evil*") + + assert bans.is_host_banned("malicious.example.com", BanFlag.ALL) + assert bans.is_host_banned("corp.server", BanFlag.ALL) + assert bans.is_host_banned("very-evil-site.net", BanFlag.ALL) + assert not bans.is_host_banned("neutral.org", BanFlag.ALL) + assert not bans.is_host_banned("angelic.net", BanFlag.ALL) + + +def test_newbie_permit_enforcement(): + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + bans.clear_all_bans() + + bans.add_banned_host("rookie.example", flags=BanFlag.NEWBIES) + assert bans.is_host_banned("rookie.example", BanFlag.NEWBIES) + + # New account should be rejected by login_with_host + assert login_with_host("rookie", "pw", "rookie.example") is None + + # Existing account may log in despite BAN_NEWBIES + assert create_account("rookie", "pw") + assert login_with_host("rookie", "pw", "rookie.example") is not None + + +def test_permit_hosts_allowed(): + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + bans.clear_all_bans() + + bans.add_banned_host("permit.example", flags=BanFlag.PERMIT) + assert bans.is_host_banned("permit.example", BanFlag.PERMIT) + + assert create_account("traveler", "pw") + # Without permit flag the host is rejected + assert login_with_host("traveler", "pw", "permit.example") is None + + assert create_account("warden", "pw") + session = SessionLocal() + account = session.query(PlayerAccount).filter_by(username="warden").first() + assert account is not None + account.is_admin = True + session.commit() + session.close() + + assert login_with_host("warden", "pw", "permit.example") is not None + + def test_ban_persistence_roundtrip(tmp_path): # Arrange bans.clear_all_bans() diff --git a/tests/test_advancement.py b/tests/test_advancement.py index 97cff5d2..8835c1d7 100644 --- a/tests/test_advancement.py +++ b/tests/test_advancement.py @@ -2,8 +2,12 @@ from mud.advancement import exp_per_level, gain_exp from mud.commands.advancement import do_practice, do_train +from mud.models import Room from mud.models.character import Character +from mud.models.constants import Position +from mud.models.mob import MobIndex from mud.skills.registry import load_skills, skill_registry +from mud.spawning.templates import MobInstance def test_gain_exp_levels_character(): char = Character(level=1, ch_class=0, race=0, exp=0) @@ -31,14 +35,107 @@ def test_gain_exp_increases_stats_and_sessions(): assert char.train > 0 -def test_practice_and_train_commands(): +def _load_fireball() -> None: skill_registry.skills.clear() + skill_registry.handlers.clear() load_skills(Path("data/skills.json")) - char = Character(practice=1, train=1) + + +def _make_trainer() -> MobInstance: + trainer_proto = MobIndex(vnum=1000, act_flags="K") + trainer = MobInstance.from_prototype(trainer_proto) + trainer.position = Position.STANDING + return trainer + + +def test_practice_requires_trainer_and_caps(): + _load_fireball() + skill = skill_registry.get("fireball") + skill.rating[0] = 4 + + room = Room(vnum=1, name="Practice Room") + char = Character( + name="Learner", + practice=2, + ch_class=0, + is_npc=False, + room=room, + perm_stat=[13, 25, 13, 13, 13], + mod_stat=[0, 0, 0, 0, 0], + skills={"fireball": 74}, + ) + room.people.append(char) + + msg = do_practice(char, "fireball") + assert msg == "You can't do that here." + assert char.practice == 2 + + trainer = _make_trainer() + trainer.position = Position.SLEEPING + room.people.append(trainer) + msg = do_practice(char, "fireball") + assert msg == "You can't do that here." + assert char.practice == 2 + + trainer.position = Position.STANDING + msg = do_practice(char, "fireball") + assert msg == "You are now learned at fireball." + assert char.practice == 1 + assert char.skills["fireball"] == char.skill_adept_cap() + + +def test_practice_applies_int_based_gain(): + _load_fireball() + skill = skill_registry.get("fireball") + skill.rating[0] = 4 + + room = Room(vnum=2, name="Practice Hall") + char = Character( + name="Scholar", + practice=1, + ch_class=0, + is_npc=False, + room=room, + perm_stat=[13, 18, 13, 13, 13], + mod_stat=[0, 0, 0, 0, 0], + skills={"fireball": 1}, + ) + room.people.extend([char, _make_trainer()]) + + learn_rate = char.get_int_learn_rate() msg = do_practice(char, "fireball") + assert msg == "You practice fireball." + expected = min(char.skill_adept_cap(), 1 + max(1, learn_rate // 4)) + assert char.skills["fireball"] == expected assert char.practice == 0 - assert char.skills["fireball"] == 25 - assert "practice fireball" in msg + + +def test_practice_rejects_unknown_skill(): + _load_fireball() + skill = skill_registry.get("fireball") + skill.rating[0] = 4 + + room = Room(vnum=3, name="Hallway") + char = Character( + name="Newbie", + practice=1, + ch_class=0, + is_npc=False, + room=room, + perm_stat=[13, 13, 13, 13, 13], + mod_stat=[0, 0, 0, 0, 0], + skills={}, + ) + room.people.extend([char, _make_trainer()]) + + msg = do_practice(char, "fireball") + assert msg == "You can't practice that." + assert char.practice == 1 + assert "fireball" not in char.skills + + +def test_train_command_increases_stats(): + char = Character(practice=0, train=1) msg = do_train(char, "hp") assert char.train == 0 assert char.max_hit > 0 diff --git a/tests/test_movement_doors.py b/tests/test_movement_doors.py new file mode 100644 index 00000000..738beebd --- /dev/null +++ b/tests/test_movement_doors.py @@ -0,0 +1,68 @@ +from mud.models.character import Character +from mud.models.constants import ( + AffectFlag, + Direction, + EX_CLOSED, + EX_NOPASS, + LEVEL_IMMORTAL, +) +from mud.models.room import Exit, Room +from mud.world import move_character + + +def _setup_rooms() -> tuple[Character, Room, Room, Exit]: + start = Room(vnum=1000, name="Start") + target = Room(vnum=1001, name="Target") + exit_obj = Exit(to_room=target, keyword="door", exit_info=0) + start.exits[Direction.NORTH.value] = exit_obj + + char = Character(name="Tester", level=1, ch_class=3, is_npc=False) + char.move = 10 + start.add_character(char) + + return char, start, target, exit_obj + + +def test_closed_door_blocks_movement() -> None: + char, start, _, exit_obj = _setup_rooms() + exit_obj.exit_info = EX_CLOSED + + result = move_character(char, "north") + + assert result == "The door is closed." + assert char.room is start + assert char.wait == 0 + + +def test_pass_door_allows_closed_door() -> None: + char, _, target, exit_obj = _setup_rooms() + exit_obj.exit_info = EX_CLOSED + char.affected_by = int(AffectFlag.PASS_DOOR) + + result = move_character(char, "north") + + assert "You walk north" in result + assert char.room is target + assert char.wait == 1 + + +def test_nopass_blocks_pass_door() -> None: + char, start, _, exit_obj = _setup_rooms() + exit_obj.exit_info = EX_CLOSED | EX_NOPASS + char.affected_by = int(AffectFlag.PASS_DOOR) + + result = move_character(char, "north") + + assert result == "The door is closed." + assert char.room is start + + +def test_immortal_bypasses_closed_door() -> None: + char, _, target, exit_obj = _setup_rooms() + exit_obj.exit_info = EX_CLOSED + char.level = LEVEL_IMMORTAL + + result = move_character(char, "north") + + assert "You walk north" in result + assert char.room is target diff --git a/tests/test_movement_privacy.py b/tests/test_movement_privacy.py new file mode 100644 index 00000000..87cf1769 --- /dev/null +++ b/tests/test_movement_privacy.py @@ -0,0 +1,86 @@ +from mud.models.character import Character +from mud.models.constants import Direction, RoomFlag, LEVEL_IMMORTAL +from mud.models.room import Exit, Room +from mud.world import move_character + + +def _setup_rooms(target_vnum: int = 2001) -> tuple[Character, Room, Room]: + start = Room(vnum=2000, name="Start") + target = Room(vnum=target_vnum, name="Target") + exit_obj = Exit(to_room=target, keyword="archway") + start.exits[Direction.NORTH.value] = exit_obj + + char = Character(name="Tester", level=10, ch_class=0, is_npc=False) + char.move = 10 + start.add_character(char) + + return char, start, target + + +def test_private_room_blocks_entry() -> None: + char, start, target = _setup_rooms() + target.room_flags = int(RoomFlag.ROOM_PRIVATE) + target.add_character(Character(name="Guest", is_npc=False)) + target.add_character(Character(name="Guest2", is_npc=False)) + + result = move_character(char, "north") + + assert result == "That room is private right now." + assert char.room is start + + +def test_solitary_room_blocks_entry() -> None: + char, start, target = _setup_rooms() + target.room_flags = int(RoomFlag.ROOM_SOLITARY) + target.add_character(Character(name="Loner", is_npc=False)) + + result = move_character(char, "north") + + assert result == "That room is private right now." + assert char.room is start + + +def test_owner_can_enter_private_room() -> None: + char, _, target = _setup_rooms() + target.room_flags = int(RoomFlag.ROOM_PRIVATE) + target.owner = "Tester" + target.add_character(Character(name="Guest", is_npc=False)) + target.add_character(Character(name="Guest2", is_npc=False)) + + result = move_character(char, "north") + + assert "You walk north" in result + assert char.room is target + + +def test_trusted_enters_private_room() -> None: + char, _, target = _setup_rooms() + target.room_flags = int(RoomFlag.ROOM_PRIVATE) + target.add_character(Character(name="Guest", is_npc=False)) + target.add_character(Character(name="Guest2", is_npc=False)) + char.level = LEVEL_IMMORTAL + + result = move_character(char, "north") + + assert "You walk north" in result + assert char.room is target + + +def test_guild_room_rejects_other_classes() -> None: + char, start, target = _setup_rooms(target_vnum=3018) + char.ch_class = 3 # warrior attempting mage guild + + result = move_character(char, "north") + + assert result == "You aren't allowed in there." + assert char.room is start + + +def test_guild_room_allows_own_class() -> None: + char, _, target = _setup_rooms(target_vnum=3018) + char.ch_class = 0 # mage guild + + result = move_character(char, "north") + + assert "You walk north" in result + assert char.room is target diff --git a/tests/test_skills.py b/tests/test_skills.py index 1fe2b270..5e243b2c 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -5,6 +5,7 @@ from mud.models.character import Character from mud.skills import SkillRegistry +from mud.utils import rng_mm def load_registry() -> SkillRegistry: @@ -49,3 +50,61 @@ def dummy(caster, target): # pragma: no cover - test helper assert result is False assert caster.mana == 5 # 20 - 15 mana cost = 5 (mana consumed even on failure) assert called == [] + + +def test_skill_use_advances_learned_percent(monkeypatch: pytest.MonkeyPatch) -> None: + reg = load_registry() + skill = reg.get("fireball") + skill.rating[0] = 4 + + caster = Character( + mana=20, + ch_class=0, + level=10, + is_npc=False, + perm_stat=[13, 18, 13, 13, 13], + mod_stat=[0, 0, 0, 0, 0], + skills={"fireball": 50}, + ) + target = Character() + + percent_rolls = iter([30, 1]) + range_rolls = iter([10]) + + monkeypatch.setattr(rng_mm, "number_percent", lambda: next(percent_rolls)) + monkeypatch.setattr(rng_mm, "number_range", lambda a, b: next(range_rolls)) + + result = reg.use(caster, "fireball", target) + assert result == 42 + assert caster.skills["fireball"] == 51 + assert caster.exp == 8 + assert any("become better" in msg for msg in caster.messages) + + +def test_skill_failure_grants_learning_xp(monkeypatch: pytest.MonkeyPatch) -> None: + reg = load_registry() + skill = reg.get("fireball") + skill.rating[0] = 4 + + caster = Character( + mana=20, + ch_class=0, + level=10, + is_npc=False, + perm_stat=[13, 18, 13, 13, 13], + mod_stat=[0, 0, 0, 0, 0], + skills={"fireball": 50}, + ) + target = Character() + + percent_rolls = iter([100, 10]) + range_rolls = iter([10, 2]) + + monkeypatch.setattr(rng_mm, "number_percent", lambda: next(percent_rolls)) + monkeypatch.setattr(rng_mm, "number_range", lambda a, b: next(range_rolls)) + + result = reg.use(caster, "fireball", target) + assert result is False + assert caster.skills["fireball"] == 52 + assert caster.exp == 8 + assert any("learn from your mistakes" in msg for msg in caster.messages) diff --git a/tests/test_spawning.py b/tests/test_spawning.py index 5de01988..619b704e 100644 --- a/tests/test_spawning.py +++ b/tests/test_spawning.py @@ -128,7 +128,57 @@ def test_reset_P_uses_last_container_instance_when_multiple(): assert counts == [1, 1] -def test_reset_GE_limits_and_shopkeeper_inventory_flag(): +def test_reset_P_limit_enforced(): + room_registry.clear(); area_registry.clear(); mob_registry.clear(); obj_registry.clear() + initialize_world('area/area.lst') + office = room_registry[3142] + area = office.area; assert area is not None + area.resets = [] + office.contents.clear() + + area.resets.append(ResetJson(command='O', arg1=3130, arg3=office.vnum)) + area.resets.append(ResetJson(command='P', arg1=3123, arg2=1, arg3=3130, arg4=1)) + area.resets.append(ResetJson(command='P', arg1=3123, arg2=1, arg3=3130, arg4=1)) + + from mud.spawning.reset_handler import apply_resets + apply_resets(area) + + desk = next((o for o in office.contents if getattr(o.prototype, 'vnum', None) == 3130), None) + assert desk is not None + contents = [getattr(getattr(it, 'prototype', None), 'vnum', None) for it in getattr(desk, 'contained_items', [])] + assert contents.count(3123) == 1 + assert getattr(obj_registry.get(3123), 'count', 0) == 1 + + +def test_reset_P_skips_when_players_present(): + room_registry.clear(); area_registry.clear(); mob_registry.clear(); obj_registry.clear() + initialize_world('area/area.lst') + office = room_registry[3142] + area = office.area; assert area is not None + area.resets = [] + office.contents.clear() + + area.resets.append(ResetJson(command='O', arg1=3130, arg3=office.vnum)) + area.resets.append(ResetJson(command='P', arg1=3123, arg2=2, arg3=3130, arg4=1)) + + from mud.spawning.reset_handler import apply_resets + apply_resets(area) + + desk = next((o for o in office.contents if getattr(o.prototype, 'vnum', None) == 3130), None) + assert desk is not None + desk.contained_items.clear() + key_proto = obj_registry.get(3123) + if key_proto is not None and hasattr(key_proto, 'count'): + key_proto.count = 0 + + area.nplayer = 1 + apply_resets(area) + + assert not any(getattr(getattr(it, 'prototype', None), 'vnum', None) == 3123 for it in getattr(desk, 'contained_items', [])) + assert getattr(key_proto, 'count', 0) == 0 + + +def test_reset_GE_limits_and_shopkeeper_inventory_flag(monkeypatch): room_registry.clear(); area_registry.clear(); mob_registry.clear(); obj_registry.clear() initialize_world('area/area.lst') room = room_registry[3001] @@ -140,6 +190,16 @@ def test_reset_GE_limits_and_shopkeeper_inventory_flag(): # 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)) + from mud.utils import rng_mm + + original_number_range = rng_mm.number_range + + def guard_number_range(start: int, end: int) -> int: + if (start, end) == (0, 4): + return 1 + return original_number_range(start, end) + + monkeypatch.setattr(rng_mm, 'number_range', guard_number_range) from mud.spawning.reset_handler import apply_resets apply_resets(area) keeper = next((p for p in room.people if getattr(getattr(p, 'prototype', None), 'vnum', None) == 3000), None) @@ -151,6 +211,37 @@ def test_reset_GE_limits_and_shopkeeper_inventory_flag(): assert getattr(item.prototype, 'extra_flags', 0) & (1 << 13) +def test_reset_G_reroll_allows_extra_copy(monkeypatch): + 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 = [] + 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)) + from mud.utils import rng_mm + + original_number_range = rng_mm.number_range + reroll_calls: list[tuple[int, int]] = [] + + def reroll_number_range(start: int, end: int) -> int: + if (start, end) == (0, 4): + reroll_calls.append((start, end)) + return 0 + return original_number_range(start, end) + + monkeypatch.setattr(rng_mm, 'number_range', reroll_number_range) + from mud.spawning.reset_handler import apply_resets + + apply_resets(area) + assert reroll_calls == [(0, 4)] + 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) == 2 + + def test_reset_mob_limits(): room_registry.clear() area_registry.clear() @@ -194,3 +285,39 @@ def test_reset_mob_limits(): if isinstance(mob, MobInstance) ] assert janitor_vnums.count(3003) == 1 + + +def test_resets_room_duplication_and_player_presence(): + room_registry.clear(); area_registry.clear(); mob_registry.clear(); obj_registry.clear() + initialize_world('area/area.lst') + office = room_registry[3142] + area = office.area; assert area is not None + area.resets = [] + office.contents.clear() + + area.resets.append(ResetJson(command='O', arg1=3130, arg3=office.vnum)) + + from mud.spawning.reset_handler import apply_resets + apply_resets(area) + + def desk_count() -> int: + return sum(1 for o in office.contents if getattr(getattr(o, 'prototype', None), 'vnum', None) == 3130) + + assert desk_count() == 1 + + apply_resets(area) + assert desk_count() == 1 + + desk = next((o for o in office.contents if getattr(o.prototype, 'vnum', None) == 3130), None) + assert desk is not None + office.contents.remove(desk) + if hasattr(desk.prototype, 'count'): + desk.prototype.count = max(0, desk.prototype.count - 1) + + area.nplayer = 1 + apply_resets(area) + assert desk_count() == 0 + + area.nplayer = 0 + apply_resets(area) + assert desk_count() == 1