rom24-quickmud-python 1.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. mud/__init__.py +0 -0
  2. mud/__main__.py +40 -0
  3. mud/account/__init__.py +20 -0
  4. mud/account/account_manager.py +62 -0
  5. mud/account/account_service.py +80 -0
  6. mud/advancement.py +62 -0
  7. mud/affects/saves.py +123 -0
  8. mud/agent/__init__.py +0 -0
  9. mud/agent/agent_protocol.py +19 -0
  10. mud/agent/character_agent.py +61 -0
  11. mud/combat/__init__.py +3 -0
  12. mud/combat/engine.py +189 -0
  13. mud/commands/__init__.py +3 -0
  14. mud/commands/admin_commands.py +77 -0
  15. mud/commands/advancement.py +36 -0
  16. mud/commands/alias_cmds.py +44 -0
  17. mud/commands/build.py +18 -0
  18. mud/commands/combat.py +16 -0
  19. mud/commands/communication.py +55 -0
  20. mud/commands/decorators.py +11 -0
  21. mud/commands/dispatcher.py +206 -0
  22. mud/commands/healer.py +81 -0
  23. mud/commands/help.py +14 -0
  24. mud/commands/imc.py +19 -0
  25. mud/commands/inspection.py +113 -0
  26. mud/commands/inventory.py +42 -0
  27. mud/commands/movement.py +71 -0
  28. mud/commands/notes.py +44 -0
  29. mud/commands/shop.py +138 -0
  30. mud/commands/socials.py +34 -0
  31. mud/config.py +59 -0
  32. mud/db/__init__.py +0 -0
  33. mud/db/init.py +27 -0
  34. mud/db/migrate_from_files.py +87 -0
  35. mud/db/migrations.py +7 -0
  36. mud/db/models.py +98 -0
  37. mud/db/seed.py +28 -0
  38. mud/db/session.py +11 -0
  39. mud/devtools/__init__.py +0 -0
  40. mud/devtools/agent_demo.py +19 -0
  41. mud/entrypoint.py +34 -0
  42. mud/game_loop.py +117 -0
  43. mud/imc/__init__.py +17 -0
  44. mud/imc/protocol.py +32 -0
  45. mud/loaders/__init__.py +27 -0
  46. mud/loaders/area_loader.py +73 -0
  47. mud/loaders/base_loader.py +38 -0
  48. mud/loaders/help_loader.py +17 -0
  49. mud/loaders/json_area_loader.py +203 -0
  50. mud/loaders/json_loader.py +285 -0
  51. mud/loaders/mob_loader.py +104 -0
  52. mud/loaders/obj_loader.py +76 -0
  53. mud/loaders/reset_loader.py +29 -0
  54. mud/loaders/room_loader.py +63 -0
  55. mud/loaders/shop_loader.py +41 -0
  56. mud/loaders/social_loader.py +16 -0
  57. mud/loaders/specials_loader.py +63 -0
  58. mud/logging/__init__.py +0 -0
  59. mud/logging/admin.py +40 -0
  60. mud/logging/agent_trace.py +9 -0
  61. mud/math/c_compat.py +27 -0
  62. mud/mobprog.py +72 -0
  63. mud/models/__init__.py +106 -0
  64. mud/models/area.py +33 -0
  65. mud/models/area_json.py +27 -0
  66. mud/models/board.py +49 -0
  67. mud/models/board_json.py +16 -0
  68. mud/models/character.py +195 -0
  69. mud/models/character_json.py +46 -0
  70. mud/models/constants.py +423 -0
  71. mud/models/conversion.py +45 -0
  72. mud/models/help.py +28 -0
  73. mud/models/help_json.py +14 -0
  74. mud/models/json_io.py +64 -0
  75. mud/models/mob.py +82 -0
  76. mud/models/note.py +29 -0
  77. mud/models/note_json.py +16 -0
  78. mud/models/obj.py +82 -0
  79. mud/models/object.py +28 -0
  80. mud/models/object_json.py +40 -0
  81. mud/models/player_json.py +29 -0
  82. mud/models/room.py +86 -0
  83. mud/models/room_json.py +46 -0
  84. mud/models/shop.py +21 -0
  85. mud/models/shop_json.py +17 -0
  86. mud/models/skill.py +25 -0
  87. mud/models/skill_json.py +20 -0
  88. mud/models/social.py +78 -0
  89. mud/models/social_json.py +20 -0
  90. mud/net/__init__.py +9 -0
  91. mud/net/ansi.py +27 -0
  92. mud/net/connection.py +174 -0
  93. mud/net/protocol.py +57 -0
  94. mud/net/session.py +21 -0
  95. mud/net/telnet_server.py +31 -0
  96. mud/network/__init__.py +0 -0
  97. mud/network/websocket_server.py +83 -0
  98. mud/network/websocket_session.py +21 -0
  99. mud/notes.py +45 -0
  100. mud/persistence.py +185 -0
  101. mud/registry.py +5 -0
  102. mud/scripts/convert_are_to_json.py +162 -0
  103. mud/scripts/convert_help_are_to_json.py +92 -0
  104. mud/scripts/convert_player_to_json.py +112 -0
  105. mud/scripts/convert_shops_to_json.py +64 -0
  106. mud/scripts/convert_skills_to_json.py +166 -0
  107. mud/scripts/convert_social_are_to_json.py +92 -0
  108. mud/scripts/load_test_data.py +17 -0
  109. mud/security/__init__.py +0 -0
  110. mud/security/bans.py +112 -0
  111. mud/security/hash_utils.py +20 -0
  112. mud/server.py +8 -0
  113. mud/skills/__init__.py +3 -0
  114. mud/skills/handlers.py +795 -0
  115. mud/skills/registry.py +97 -0
  116. mud/spawning/__init__.py +1 -0
  117. mud/spawning/mob_spawner.py +13 -0
  118. mud/spawning/obj_spawner.py +18 -0
  119. mud/spawning/reset_handler.py +222 -0
  120. mud/spawning/templates.py +63 -0
  121. mud/spec_funs.py +57 -0
  122. mud/time.py +48 -0
  123. mud/utils/rng_mm.py +123 -0
  124. mud/wiznet.py +74 -0
  125. mud/world/__init__.py +11 -0
  126. mud/world/linking.py +31 -0
  127. mud/world/look.py +29 -0
  128. mud/world/movement.py +135 -0
  129. mud/world/world_state.py +179 -0
  130. rom24_quickmud_python-1.2.2.dist-info/METADATA +236 -0
  131. rom24_quickmud_python-1.2.2.dist-info/RECORD +135 -0
  132. rom24_quickmud_python-1.2.2.dist-info/WHEEL +5 -0
  133. rom24_quickmud_python-1.2.2.dist-info/entry_points.txt +2 -0
  134. rom24_quickmud_python-1.2.2.dist-info/licenses/LICENSE +21 -0
  135. rom24_quickmud_python-1.2.2.dist-info/top_level.txt +1 -0
mud/skills/registry.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from importlib import import_module
5
+ from pathlib import Path
6
+ from random import Random
7
+ from typing import Callable, Dict, Optional
8
+
9
+ from mud.models import Skill, SkillJson
10
+ from mud.utils import rng_mm
11
+ from mud.models.json_io import dataclass_from_dict
12
+
13
+
14
+ class SkillRegistry:
15
+ """Load skill metadata from JSON and dispatch handlers."""
16
+
17
+ def __init__(self, rng: Optional[Random] = None) -> None:
18
+ self.skills: Dict[str, Skill] = {}
19
+ self.handlers: Dict[str, Callable] = {}
20
+ self.rng = rng or Random()
21
+
22
+ def load(self, path: Path) -> None:
23
+ """Load skill definitions from a JSON file."""
24
+ with path.open() as fp:
25
+ data = json.load(fp)
26
+ module = import_module("mud.skills.handlers")
27
+ for entry in data:
28
+ skill_json = dataclass_from_dict(SkillJson, entry)
29
+ skill = Skill.from_json(skill_json)
30
+ handler = getattr(module, skill.function)
31
+ self.skills[skill.name] = skill
32
+ self.handlers[skill.name] = handler
33
+
34
+ def get(self, name: str) -> Skill:
35
+ return self.skills[name]
36
+
37
+ def use(self, caster, name: str, target=None):
38
+ """Execute a skill and handle resource costs and failure.
39
+
40
+ Parity: If the caster has a learned percentage for this skill in
41
+ `caster.skills[name]` (0..100), success is determined by a ROM-style
42
+ percent roll (number_percent) against that learned value. If no
43
+ learned value is present, fall back to `failure_rate` as before.
44
+ """
45
+ skill = self.get(name)
46
+ if caster.mana < skill.mana_cost:
47
+ raise ValueError("not enough mana")
48
+
49
+ cooldowns = getattr(caster, "cooldowns", {})
50
+ if cooldowns.get(name, 0) > 0:
51
+ raise ValueError("skill on cooldown")
52
+
53
+ caster.mana -= skill.mana_cost
54
+ # ROM parity: prefer per-character learned% when available
55
+ learned = None
56
+ try:
57
+ learned = caster.skills.get(name) # 0..100
58
+ except Exception:
59
+ # Characters without a `skills` mapping should not error
60
+ learned = None
61
+
62
+ if learned is not None:
63
+ # Success when roll <= learned (ROM practice mechanics)
64
+ if rng_mm.number_percent() > int(learned):
65
+ cooldowns[name] = skill.cooldown
66
+ caster.cooldowns = cooldowns
67
+ return False
68
+ else:
69
+ # Fallback: use failure_rate gate (legacy behavior)
70
+ # Convert float failure_rate (0.0..1.0) to percentage threshold 0..100
71
+ failure_threshold = int(round(skill.failure_rate * 100))
72
+ if rng_mm.number_percent() <= failure_threshold:
73
+ cooldowns[name] = skill.cooldown
74
+ caster.cooldowns = cooldowns
75
+ return False
76
+ # Success path (roll > threshold): execute handler
77
+
78
+ result = self.handlers[name](caster, target)
79
+ cooldowns[name] = skill.cooldown
80
+ caster.cooldowns = cooldowns
81
+ return result
82
+
83
+ def tick(self, character) -> None:
84
+ """Reduce active cooldowns on a character by one tick."""
85
+ cooldowns = getattr(character, "cooldowns", {})
86
+ for key in list(cooldowns):
87
+ cooldowns[key] -= 1
88
+ if cooldowns[key] <= 0:
89
+ del cooldowns[key]
90
+ character.cooldowns = cooldowns
91
+
92
+
93
+ skill_registry = SkillRegistry()
94
+
95
+
96
+ def load_skills(path: Path) -> None:
97
+ skill_registry.load(path)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+ from typing import Optional
3
+
4
+ from mud.registry import mob_registry
5
+ from .templates import MobInstance
6
+
7
+
8
+ def spawn_mob(vnum: int) -> Optional[MobInstance]:
9
+ proto = mob_registry.get(vnum)
10
+ if not proto:
11
+ return None
12
+ mob = MobInstance.from_prototype(proto)
13
+ return mob
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+ from typing import Optional
3
+
4
+ from mud.registry import obj_registry
5
+ from mud.models.object import Object
6
+
7
+
8
+ def spawn_object(vnum: int) -> Optional[Object]:
9
+ proto = obj_registry.get(vnum)
10
+ if not proto:
11
+ return None
12
+ inst = Object(instance_id=None, prototype=proto)
13
+ # Copy prototype values for runtime mutation compatibility
14
+ try:
15
+ inst.value = list(getattr(proto, 'value', [0, 0, 0, 0, 0]))
16
+ except Exception:
17
+ inst.value = [0, 0, 0, 0, 0]
18
+ return inst
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ from typing import Dict, List, Optional
4
+
5
+ from mud.models.area import Area
6
+ from mud.models.constants import ITEM_INVENTORY
7
+ from mud.registry import room_registry, area_registry
8
+ from .mob_spawner import spawn_mob
9
+ from .obj_spawner import spawn_object
10
+ from .templates import MobInstance
11
+ from mud.utils import rng_mm
12
+
13
+ RESET_TICKS = 3
14
+
15
+
16
+ def _compute_object_level(obj: object, mob: object) -> int:
17
+ """Approximate ROM object level computation for G/E resets.
18
+
19
+ Mirrors src/db.c case 'G'/'E' for shopkeepers and equips (simplified):
20
+ - WAND: 10..20
21
+ - STAFF: 15..25
22
+ - ARMOR: 5..15
23
+ - WEAPON: 5..15
24
+ - TREASURE: 10..20
25
+ - Default: 0
26
+ For new-format objects, or unrecognized types, return 0.
27
+ """
28
+ try:
29
+ item_type = int(getattr(getattr(obj, 'prototype', None), 'item_type', 0))
30
+ except Exception:
31
+ item_type = 0
32
+ from mud.models.constants import ItemType
33
+ if item_type == int(ItemType.WAND):
34
+ return rng_mm.number_range(10, 20)
35
+ if item_type == int(ItemType.STAFF):
36
+ return rng_mm.number_range(15, 25)
37
+ if item_type == int(ItemType.ARMOR):
38
+ return rng_mm.number_range(5, 15)
39
+ if item_type == int(ItemType.WEAPON):
40
+ return rng_mm.number_range(5, 15)
41
+ if item_type == int(ItemType.TREASURE):
42
+ return rng_mm.number_range(10, 20)
43
+ return 0
44
+
45
+
46
+ def apply_resets(area: Area) -> None:
47
+ """Populate rooms based on simplified reset data."""
48
+ last_mob = None
49
+ last_obj: Optional[object] = None
50
+ # Track spawned objects per prototype vnum to support simple 'P' lookups
51
+ spawned_objects: Dict[int, List[object]] = {}
52
+ for reset in area.resets:
53
+ cmd = reset.command.upper()
54
+ if cmd == 'M':
55
+ mob_vnum = reset.arg2 or 0
56
+ room_vnum = reset.arg4 or 0
57
+ mob = spawn_mob(mob_vnum)
58
+ room = room_registry.get(room_vnum)
59
+ if mob and room:
60
+ room.add_mob(mob)
61
+ last_mob = mob
62
+ last_obj = None
63
+ else:
64
+ logging.warning('Invalid M reset %s -> %s', mob_vnum, room_vnum)
65
+ elif cmd == 'O':
66
+ obj_vnum = reset.arg2 or 0
67
+ room_vnum = reset.arg4 or 0
68
+ obj = spawn_object(obj_vnum)
69
+ room = room_registry.get(room_vnum)
70
+ if obj and room:
71
+ room.add_object(obj)
72
+ # Update last object instance and index by vnum
73
+ last_obj = obj
74
+ spawned_objects.setdefault(obj_vnum, []).append(obj)
75
+ else:
76
+ logging.warning('Invalid O reset %s -> %s', obj_vnum, room_vnum)
77
+ elif cmd == 'G':
78
+ obj_vnum = reset.arg2 or 0
79
+ limit = int(reset.arg3 or 1)
80
+ if not last_mob:
81
+ logging.warning('Invalid G reset %s (no LastMob)', obj_vnum)
82
+ continue
83
+ # Respect simple per-mob limit for this vnum
84
+ existing = [o for o in getattr(last_mob, 'inventory', [])
85
+ if getattr(getattr(o, 'prototype', None), 'vnum', None) == obj_vnum]
86
+ if len(existing) >= limit:
87
+ continue
88
+ obj = spawn_object(obj_vnum)
89
+ if obj:
90
+ obj.level = _compute_object_level(obj, last_mob)
91
+ # Shopkeepers receive inventory copies
92
+ # Detect shopkeeper by registry since pShop not wired on prototype
93
+ try:
94
+ from mud.registry import shop_registry
95
+ is_shopkeeper = getattr(getattr(last_mob, 'prototype', None), 'vnum', None) in shop_registry
96
+ except Exception:
97
+ is_shopkeeper = False
98
+
99
+ if is_shopkeeper:
100
+ if hasattr(obj.prototype, 'extra_flags'):
101
+ from mud.models.constants import ExtraFlag
102
+ if isinstance(obj.prototype.extra_flags, str):
103
+ # Legacy .are format uses flag letters - convert to proper flags
104
+ from mud.models.constants import convert_flags_from_letters
105
+ current_flags = convert_flags_from_letters(obj.prototype.extra_flags, ExtraFlag)
106
+ obj.prototype.extra_flags = current_flags | ITEM_INVENTORY
107
+ else:
108
+ obj.prototype.extra_flags |= ITEM_INVENTORY
109
+ last_mob.add_to_inventory(obj)
110
+ last_obj = obj
111
+ spawned_objects.setdefault(obj_vnum, []).append(obj)
112
+ else:
113
+ logging.warning('Invalid G reset %s', obj_vnum)
114
+ elif cmd == 'E':
115
+ obj_vnum = reset.arg2 or 0
116
+ limit = int(reset.arg3 or 1)
117
+ slot = reset.arg4 or 0
118
+ if not last_mob:
119
+ logging.warning('Invalid E reset %s (no LastMob)', obj_vnum)
120
+ continue
121
+ existing = [o for o in getattr(last_mob, 'inventory', [])
122
+ if getattr(getattr(o, 'prototype', None), 'vnum', None) == obj_vnum]
123
+ if len(existing) >= limit:
124
+ continue
125
+ obj = spawn_object(obj_vnum)
126
+ if obj:
127
+ obj.level = _compute_object_level(obj, last_mob)
128
+ try:
129
+ from mud.registry import shop_registry
130
+ is_shopkeeper = getattr(getattr(last_mob, 'prototype', None), 'vnum', None) in shop_registry
131
+ except Exception:
132
+ is_shopkeeper = False
133
+ if is_shopkeeper:
134
+ if hasattr(obj.prototype, 'extra_flags'):
135
+ from mud.models.constants import ExtraFlag
136
+ if isinstance(obj.prototype.extra_flags, str):
137
+ # Legacy .are format uses flag letters - convert to proper flags
138
+ from mud.models.constants import convert_flags_from_letters
139
+ current_flags = convert_flags_from_letters(obj.prototype.extra_flags, ExtraFlag)
140
+ obj.prototype.extra_flags = current_flags | ITEM_INVENTORY
141
+ else:
142
+ obj.prototype.extra_flags |= ITEM_INVENTORY
143
+ last_mob.equip(obj, slot)
144
+ last_obj = obj
145
+ spawned_objects.setdefault(obj_vnum, []).append(obj)
146
+ else:
147
+ logging.warning('Invalid E reset %s', obj_vnum)
148
+ elif cmd == 'P':
149
+ obj_vnum = reset.arg2 or 0
150
+ container_vnum = reset.arg4 or 0
151
+ count = max(1, int(reset.arg3 or 1)) # how many to place
152
+ if container_vnum <= 0:
153
+ logging.warning('Invalid P reset %s -> %s', obj_vnum, container_vnum)
154
+ continue
155
+ # Prefer the last created object instance if it matches the container vnum
156
+ container_obj: Optional[object] = None
157
+ if last_obj and getattr(getattr(last_obj, 'prototype', None), 'vnum', None) == container_vnum:
158
+ container_obj = last_obj
159
+ # Otherwise, fall back to the most recent spawned container by vnum
160
+ if not container_obj:
161
+ lst = spawned_objects.get(container_vnum) or []
162
+ container_obj = lst[-1] if lst else None
163
+ if not container_obj:
164
+ logging.warning('Invalid P reset %s -> %s (no container instance)', obj_vnum, container_vnum)
165
+ continue
166
+ # Determine existing count inside
167
+ existing = [o for o in getattr(container_obj, 'contained_items', [])
168
+ if getattr(getattr(o, 'prototype', None), 'vnum', None) == obj_vnum]
169
+ to_make = max(0, count - len(existing))
170
+ for _ in range(to_make):
171
+ obj = spawn_object(obj_vnum)
172
+ if not obj:
173
+ break
174
+ getattr(container_obj, 'contained_items').append(obj)
175
+ spawned_objects.setdefault(obj_vnum, []).append(obj)
176
+ # After population, set last_obj to the container (mirrors ROM behavior)
177
+ # Lock-state fix: reset container instance's value[1] to prototype's value[1]
178
+ try:
179
+ container_obj.value[1] = container_obj.prototype.value[1]
180
+ except Exception:
181
+ pass
182
+ last_obj = container_obj
183
+ elif cmd == 'R':
184
+ room_vnum = reset.arg1 or 0
185
+ max_dirs = int(reset.arg2 or 0)
186
+ room = room_registry.get(room_vnum)
187
+ if not room or not room.exits:
188
+ logging.warning("Invalid R reset %s", room_vnum)
189
+ continue
190
+ n = min(max_dirs, len(room.exits))
191
+ # Fisher–Yates-like partial shuffle matching ROM loop
192
+ for d0 in range(0, max(0, n - 1)):
193
+ d1 = rng_mm.number_range(d0, n - 1)
194
+ room.exits[d0], room.exits[d1] = room.exits[d1], room.exits[d0]
195
+
196
+
197
+ def reset_area(area: Area) -> None:
198
+ """Clear existing spawns and reapply resets for an area."""
199
+ for room in room_registry.values():
200
+ if room.area is area:
201
+ room.contents.clear()
202
+ room.people = [p for p in room.people if not isinstance(p, MobInstance)]
203
+ apply_resets(area)
204
+
205
+
206
+ def reset_tick() -> None:
207
+ """Advance area ages and trigger resets when empty."""
208
+ for area in area_registry.values():
209
+ area.nplayer = sum(
210
+ 1
211
+ for room in room_registry.values()
212
+ if room.area is area
213
+ for p in room.people
214
+ if not isinstance(p, MobInstance)
215
+ )
216
+ if area.nplayer > 0:
217
+ area.age = 0
218
+ continue
219
+ area.age += 1
220
+ if area.age >= RESET_TICKS:
221
+ reset_area(area)
222
+ area.age = 0
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import List, Optional
4
+
5
+ from mud.models.object import Object
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from mud.models.mob import MobIndex
11
+ from mud.models.obj import ObjIndex
12
+ from mud.models.object import Object
13
+
14
+
15
+ @dataclass
16
+ class ObjectInstance:
17
+ """Runtime instance of an object."""
18
+ name: Optional[str]
19
+ item_type: int
20
+ prototype: ObjIndex
21
+ short_descr: Optional[str] = None
22
+ location: Optional['Room'] = None
23
+ contained_items: List['ObjectInstance'] = field(default_factory=list)
24
+
25
+ def move_to_room(self, room: 'Room') -> None:
26
+ if self.location and hasattr(self.location, 'contents'):
27
+ if self in self.location.contents:
28
+ self.location.contents.remove(self)
29
+ room.contents.append(self)
30
+ self.location = room
31
+
32
+
33
+ @dataclass
34
+ class MobInstance:
35
+ """Runtime instance of a mob (NPC)."""
36
+ name: Optional[str]
37
+ level: int
38
+ current_hp: int
39
+ prototype: MobIndex
40
+ inventory: List[Object] = field(default_factory=list)
41
+ room: Optional['Room'] = None
42
+ # Minimal encumbrance fields to interoperate with move_character
43
+ carry_weight: int = 0
44
+ carry_number: int = 0
45
+
46
+ @classmethod
47
+ def from_prototype(cls, proto: MobIndex) -> 'MobInstance':
48
+ return cls(name=proto.short_descr or proto.player_name,
49
+ level=proto.level,
50
+ current_hp=proto.hit[1],
51
+ prototype=proto)
52
+
53
+ def move_to_room(self, room: 'Room') -> None:
54
+ if self.room and self in self.room.people:
55
+ self.room.people.remove(self)
56
+ room.people.append(self)
57
+ self.room = room
58
+
59
+ def add_to_inventory(self, obj: Object) -> None:
60
+ self.inventory.append(obj)
61
+
62
+ def equip(self, obj: Object, slot: int) -> None: # stub
63
+ self.add_to_inventory(obj)
mud/spec_funs.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Callable
3
+ from mud.utils import rng_mm
4
+
5
+ spec_fun_registry: dict[str, Callable[..., Any]] = {}
6
+
7
+ def register_spec_fun(name: str, func: Callable[..., Any]) -> None:
8
+ """Register *func* under *name*, storing key in lowercase."""
9
+ spec_fun_registry[name.lower()] = func
10
+
11
+ def get_spec_fun(name: str) -> Callable[..., Any] | None:
12
+ """Return a spec_fun for *name* using case-insensitive lookup."""
13
+ return spec_fun_registry.get(name.lower())
14
+
15
+
16
+ def run_npc_specs() -> None:
17
+ """Invoke registered spec_funs for NPCs in all rooms.
18
+
19
+ For each NPC (MobInstance) present in any room, if its prototype has a
20
+ non-empty ``spec_fun`` name and a function is registered under that name,
21
+ call it with the mob instance.
22
+ """
23
+ from mud.registry import room_registry
24
+
25
+ for room in list(room_registry.values()):
26
+ for entity in list(getattr(room, "people", [])):
27
+ proto = getattr(entity, "prototype", None)
28
+ name = getattr(proto, "spec_fun", None)
29
+ if not name:
30
+ continue
31
+ func = get_spec_fun(name)
32
+ if func is None:
33
+ continue
34
+ try:
35
+ func(entity)
36
+ except Exception:
37
+ # Spec fun failures must not break the tick loop
38
+ continue
39
+
40
+
41
+ # --- Minimal ROM-like spec functions (rng_mm parity) ---
42
+
43
+ def spec_cast_adept(mob: Any) -> bool:
44
+ """Simplified Adept spec that uses ROM RNG.
45
+
46
+ ROM's `spec_cast_adept` periodically casts helpful spells on players.
47
+ For parity of RNG usage, we roll `number_percent()` and return True when
48
+ the roll is small. This function exists primarily to validate that the
49
+ Mitchell–Moore RNG wiring matches ROM semantics.
50
+ """
51
+ roll = rng_mm.number_percent()
52
+ # Return True on low roll to signal an action occurred (simplified).
53
+ return roll <= 25
54
+
55
+
56
+ # Convenience registration name matching ROM conventions
57
+ register_spec_fun("spec_cast_adept", spec_cast_adept)
mud/time.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import IntEnum
5
+ from typing import List
6
+
7
+
8
+ class Sunlight(IntEnum):
9
+ DARK = 0
10
+ RISE = 1
11
+ LIGHT = 2
12
+ SET = 3
13
+
14
+
15
+ @dataclass
16
+ class TimeInfo:
17
+ hour: int = 0
18
+ day: int = 0
19
+ month: int = 0
20
+ year: int = 0
21
+ sunlight: Sunlight = Sunlight.DARK
22
+
23
+ def advance_hour(self) -> List[str]:
24
+ """Advance time by one hour and return broadcast messages."""
25
+ messages: List[str] = []
26
+ self.hour += 1
27
+ if self.hour >= 24:
28
+ self.hour = 0
29
+ self.day += 1
30
+ if self.day >= 35:
31
+ self.day = 0
32
+ self.month += 1
33
+ if self.month >= 17:
34
+ self.month = 0
35
+ self.year += 1
36
+ if self.hour == 5:
37
+ self.sunlight = Sunlight.LIGHT
38
+ messages.append("The sun rises in the east.")
39
+ elif self.hour == 19:
40
+ self.sunlight = Sunlight.SET
41
+ messages.append("The sun slowly disappears in the west.")
42
+ elif self.hour == 20:
43
+ self.sunlight = Sunlight.DARK
44
+ messages.append("The night has begun.")
45
+ return messages
46
+
47
+
48
+ time_info = TimeInfo()
mud/utils/rng_mm.py ADDED
@@ -0,0 +1,123 @@
1
+ """Mitchell–Moore RNG for ROM parity.
2
+
3
+ Implements ROM's RNG surface with C-style gating:
4
+ - number_mm(): Mitchell–Moore generator (OLD_RAND branch in ROM) — returns a
5
+ non‑negative int with lower bits used by callers.
6
+ - number_percent(): returns 1..100 inclusive using bitmask + while-gate.
7
+ - number_range(a,b): ROM power-of-two mask with while-gate to avoid bias.
8
+ - number_bits(width): lower ``width`` bits of ``number_mm()``.
9
+ - dice(n,size): sum of ``number_range(1,size)`` repeated ``n`` times.
10
+
11
+ References:
12
+ - C src/db.c:number_mm/number_percent/number_range/number_bits/dice
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ import time
18
+ import os
19
+ from typing import List
20
+
21
+
22
+ MASK_30 = (1 << 30) - 1
23
+
24
+
25
+ @dataclass
26
+ class _MMState:
27
+ state: List[int]
28
+ i1: int
29
+ i2: int
30
+
31
+
32
+ _mm: _MMState | None = None
33
+
34
+
35
+ def _init_state(seed: int) -> _MMState:
36
+ # Mirrors ROM init_mm() when OLD_RAND is defined
37
+ state = [0] * 55
38
+ i1 = 55 - 55 # 0
39
+ i2 = 55 - 24 # 31
40
+ state[0] = seed & MASK_30
41
+ state[1] = 1
42
+ for i in range(2, 55):
43
+ state[i] = (state[i - 1] + state[i - 2]) & MASK_30
44
+ return _MMState(state=state, i1=i1, i2=i2)
45
+
46
+
47
+ def _ensure_mm() -> _MMState:
48
+ global _mm
49
+ if _mm is None:
50
+ # Default seed: time ^ pid like ROM's srandom(time ^ getpid) branch
51
+ seed = int(time.time()) ^ os.getpid()
52
+ _mm = _init_state(seed)
53
+ return _mm
54
+
55
+
56
+ def seed_mm(seed: int) -> None:
57
+ """Seed the Mitchell–Moore generator deterministically."""
58
+ global _mm
59
+ _mm = _init_state(int(seed))
60
+
61
+
62
+ def number_mm() -> int:
63
+ """ROM Mitchell–Moore output with 6 LSBs discarded (>> 6 in callers).
64
+
65
+ We return the full value; public helpers mask/shift per ROM usage.
66
+ """
67
+ mm = _ensure_mm()
68
+ iRand = (mm.state[mm.i1] + mm.state[mm.i2]) & MASK_30
69
+ mm.state[mm.i1] = iRand
70
+ mm.i1 += 1
71
+ if mm.i1 == 55:
72
+ mm.i1 = 0
73
+ mm.i2 += 1
74
+ if mm.i2 == 55:
75
+ mm.i2 = 0
76
+ # Return with 6 LSBs discarded to mirror ROM behavior.
77
+ return iRand >> 6
78
+
79
+
80
+ def number_percent() -> int:
81
+ """Return 1..100 inclusive using ROM's 7-bit mask + while-gate."""
82
+ # while ((percent = number_mm() & (128 - 1)) > 99);
83
+ # return 1 + percent;
84
+ percent = number_mm() & (128 - 1)
85
+ while percent > 99:
86
+ percent = number_mm() & (128 - 1)
87
+ return 1 + percent
88
+
89
+
90
+ def number_range(a: int, b: int) -> int:
91
+ """Return integer in [a, b] inclusive using power-of-two mask + gate."""
92
+ if a == 0 and b == 0:
93
+ return 0
94
+ if a > b:
95
+ a, b = b, a
96
+ to = b - a + 1
97
+ if to <= 1:
98
+ return a
99
+ power = 2
100
+ while power < to:
101
+ power <<= 1
102
+ # while ((number = number_mm () & (power - 1)) >= to);
103
+ number = number_mm() & (power - 1)
104
+ while number >= to:
105
+ number = number_mm() & (power - 1)
106
+ return a + number
107
+
108
+
109
+ def number_bits(width: int) -> int:
110
+ if width <= 0:
111
+ return 0
112
+ return number_mm() & ((1 << width) - 1)
113
+
114
+
115
+ def dice(number: int, size: int) -> int:
116
+ if size == 0:
117
+ return 0
118
+ if size == 1:
119
+ return number
120
+ total = 0
121
+ for _ in range(number):
122
+ total += number_range(1, size)
123
+ return total