rom24-quickmud-python 2.7.0__py3-none-any.whl → 2.9.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 (71) hide show
  1. mud/account/account_manager.py +301 -75
  2. mud/account/account_service.py +23 -18
  3. mud/advancement.py +8 -2
  4. mud/affects/engine.py +54 -0
  5. mud/ai/__init__.py +9 -65
  6. mud/combat/death.py +3 -1
  7. mud/combat/engine.py +209 -76
  8. mud/combat/messages.py +67 -31
  9. mud/commands/admin_commands.py +4 -1
  10. mud/commands/advancement.py +14 -8
  11. mud/commands/auto_settings.py +32 -3
  12. mud/commands/build.py +1184 -95
  13. mud/commands/character.py +2 -4
  14. mud/commands/combat.py +108 -59
  15. mud/commands/communication.py +108 -18
  16. mud/commands/dispatcher.py +92 -18
  17. mud/commands/imm_admin.py +8 -0
  18. mud/commands/imm_emote.py +8 -3
  19. mud/commands/imm_olc.py +292 -63
  20. mud/commands/info.py +10 -6
  21. mud/commands/info_extended.py +88 -51
  22. mud/commands/inventory.py +5 -2
  23. mud/commands/misc_info.py +155 -146
  24. mud/commands/notes.py +42 -1
  25. mud/commands/remaining_rom.py +44 -6
  26. mud/commands/session.py +36 -33
  27. mud/db/migrations.py +66 -0
  28. mud/db/models.py +48 -1
  29. mud/db/serializers.py +622 -0
  30. mud/game_loop.py +295 -65
  31. mud/groups/xp.py +30 -30
  32. mud/handler.py +28 -26
  33. mud/loaders/base_loader.py +2 -4
  34. mud/loaders/json_loader.py +14 -6
  35. mud/loaders/obj_loader.py +15 -10
  36. mud/loaders/room_loader.py +21 -2
  37. mud/mob_cmds.py +3 -9
  38. mud/models/__init__.py +1 -2
  39. mud/models/board.py +1 -0
  40. mud/models/character.py +194 -19
  41. mud/models/mob.py +25 -0
  42. mud/models/obj.py +11 -49
  43. mud/models/object.py +63 -2
  44. mud/models/room.py +14 -1
  45. mud/models/titles.py +292 -0
  46. mud/models/weapon_table.py +86 -0
  47. mud/music/__init__.py +73 -12
  48. mud/net/connection.py +363 -98
  49. mud/network/websocket_server.py +21 -13
  50. mud/network/websocket_stream.py +5 -1
  51. mud/rom_api.py +18 -5
  52. mud/scripts/convert_are_to_json.py +1 -1
  53. mud/skills/handlers.py +51 -57
  54. mud/skills/registry.py +4 -7
  55. mud/spawning/obj_spawner.py +6 -1
  56. mud/spawning/templates.py +0 -1
  57. mud/utils/act.py +9 -5
  58. mud/utils/prompt.py +96 -6
  59. mud/wiznet.py +17 -0
  60. mud/world/char_find.py +4 -0
  61. mud/world/look.py +18 -29
  62. mud/world/time_persistence.py +61 -0
  63. mud/world/vision.py +31 -0
  64. mud/world/world_state.py +9 -0
  65. {rom24_quickmud_python-2.7.0.dist-info → rom24_quickmud_python-2.9.2.dist-info}/METADATA +30 -41
  66. {rom24_quickmud_python-2.7.0.dist-info → rom24_quickmud_python-2.9.2.dist-info}/RECORD +70 -67
  67. mud/persistence.py +0 -1239
  68. {rom24_quickmud_python-2.7.0.dist-info → rom24_quickmud_python-2.9.2.dist-info}/WHEEL +0 -0
  69. {rom24_quickmud_python-2.7.0.dist-info → rom24_quickmud_python-2.9.2.dist-info}/entry_points.txt +0 -0
  70. {rom24_quickmud_python-2.7.0.dist-info → rom24_quickmud_python-2.9.2.dist-info}/licenses/LICENSE +0 -0
  71. {rom24_quickmud_python-2.7.0.dist-info → rom24_quickmud_python-2.9.2.dist-info}/top_level.txt +0 -0
@@ -1,34 +1,75 @@
1
+ """Account manager — DB-canonical persistence (INV-008 Phase 2).
2
+
3
+ The DB row (mud.db.models.Character) is the single source of truth for ALL
4
+ player gameplay state. The JSON pfile path (mud.persistence) has been removed.
5
+
6
+ ``save_character`` writes all 71 fields to the DB row via ``save_character_to_db``.
7
+ ``load_character`` queries the DB row and returns a fully-initialized runtime
8
+ Character via ``from_orm``.
9
+
10
+ ROM Reference: src/save.c fread_char / fwrite_char — the C engine's pfile is
11
+ the single source of truth; the only external auth store is the pfile itself.
12
+ Python equivalent: the DB row owns both auth and all gameplay state.
13
+
14
+ INV-003: ``character_registry`` membership is maintained — ``load_character``
15
+ appends to the registry after a successful DB load.
16
+
17
+ INV-008 (DB-CANONICAL): The invariant has been reversed — the DB row is now
18
+ the canonical source, not the JSON pfile. There is no JSON fallback path.
19
+ """
20
+
1
21
  from __future__ import annotations
2
22
 
3
- import json
23
+ import time
24
+ from typing import TYPE_CHECKING
4
25
 
5
26
  from mud.db.models import Character as DBCharacter
6
27
  from mud.db.session import SessionLocal
7
- from mud.models.character import Character, from_orm
28
+
29
+ if TYPE_CHECKING:
30
+ from sqlalchemy.orm import Session
31
+ from mud.models.character import Character, character_registry, from_orm
8
32
  from mud.models.constants import ROOM_VNUM_LIMBO, ROOM_VNUM_TEMPLE
9
- from mud.models.conversion import (
10
- load_objects_for_character,
11
- save_objects_for_character,
33
+ from mud.db.serializers import (
34
+ _normalize_int_list,
35
+ _serialize_colour_table,
36
+ _serialize_object,
37
+ _serialize_pet,
38
+ _serialize_skill_map,
39
+ _serialize_groups,
12
40
  )
13
41
 
14
42
 
15
43
  def load_character(char_name: str, _ignored: str | None = None) -> Character | None:
16
- """Load a character by name from the database.
44
+ """Load a character by name from the DB row (DB-canonical path).
17
45
 
18
46
  The second argument is accepted but ignored for backward compatibility;
19
- previously it was a username (account name). Characters are now
20
- standalone identities — mirroring ROM src/save.c:fread_char.
47
+ previously it was a username (account name). Characters are standalone
48
+ identities — mirroring ROM src/save.c:fread_char.
49
+
50
+ The loaded character is appended to ``character_registry`` (INV-003).
21
51
  """
22
52
  session = None
23
53
  try:
24
54
  session = SessionLocal()
25
55
  db_char = session.query(DBCharacter).filter(DBCharacter.name == char_name).first()
26
- char = from_orm(db_char) if db_char else None
27
- if char and db_char:
28
- char.inventory, char.equipment = load_objects_for_character(db_char)
56
+ if db_char is None:
57
+ return None
58
+ char = from_orm(db_char)
59
+ if char is not None:
60
+ # INV-009 REGISTRY-DISCONNECT-CLEANUP / dedup-by-name:
61
+ # ROM only ever has one player object by a given name in
62
+ # char_list. Drop any prior Character with the same name
63
+ # (e.g. the level=0 bare-row load during nanny name/password
64
+ # phase) before appending the freshly-loaded one. This
65
+ # complements INV-003 and prevents in-session duplicates
66
+ # surfacing through the promote-from-bare-row path.
67
+ for prior in [c for c in character_registry if getattr(c, "name", None) == char.name]:
68
+ character_registry.remove(prior)
69
+ character_registry.append(char) # INV-003
29
70
  return char
30
71
  except Exception as e:
31
- print(f"[ERROR] Failed to load character {char_name}: {e}")
72
+ print(f"[ERROR] Failed to load character {char_name} from DB: {e}")
32
73
  return None
33
74
  finally:
34
75
  if session:
@@ -36,73 +77,258 @@ def load_character(char_name: str, _ignored: str | None = None) -> Character | N
36
77
 
37
78
 
38
79
  def save_character(character: Character) -> None:
80
+ """Persist ``character`` to the DB row (DB-canonical path).
81
+
82
+ UPDATE-only: the character row must already exist (created via
83
+ account_service.create_character). If the row is not found, returns silently.
84
+
85
+ ROM Reference: src/save.c fwrite_char — all fields persisted.
86
+ # mirroring mud/persistence.py:save_character (now removed for JSON path)
87
+ """
39
88
  session = None
40
89
  try:
41
90
  session = SessionLocal()
42
- db_char = session.query(DBCharacter).filter_by(name=character.name).first()
43
- if not db_char:
44
- print(f"[WARN] Character '{character.name}' not found in database, creating new record")
45
- db_char = DBCharacter(name=character.name)
46
- session.add(db_char)
47
-
48
- if db_char:
49
- db_char.level = character.level
50
- db_char.hp = character.hit
51
- db_char.race = int(character.race or 0)
52
- db_char.ch_class = int(character.ch_class or 0)
53
- pcdata = getattr(character, "pcdata", None)
54
- true_sex_value = int(getattr(pcdata, "true_sex", getattr(character, "sex", 0)) or 0)
55
- db_char.true_sex = true_sex_value
56
- db_char.sex = int(character.sex or true_sex_value or 0)
57
- db_char.alignment = int(character.alignment or 0)
58
- db_char.act = int(getattr(character, "act", 0) or 0)
59
- db_char.hometown_vnum = int(character.hometown_vnum or 0)
60
- db_char.perm_stats = json.dumps([int(val) for val in character.perm_stat])
61
- db_char.size = int(character.size or 0)
62
- db_char.form = int(character.form or 0)
63
- db_char.parts = int(character.parts or 0)
64
- db_char.imm_flags = int(character.imm_flags or 0)
65
- db_char.res_flags = int(character.res_flags or 0)
66
- db_char.vuln_flags = int(character.vuln_flags or 0)
67
- db_char.practice = int(character.practice or 0)
68
- db_char.train = int(character.train or 0)
69
-
70
- # Save perm stats from pcdata (ROM src/handler.c stores perm_hit/perm_mana/perm_move)
71
- if pcdata:
72
- db_char.perm_hit = int(getattr(pcdata, "perm_hit", character.max_hit or 20))
73
- db_char.perm_mana = int(getattr(pcdata, "perm_mana", character.max_mana or 100))
74
- db_char.perm_move = int(getattr(pcdata, "perm_move", character.max_move or 100))
75
- else:
76
- db_char.perm_hit = int(character.max_hit or 20)
77
- db_char.perm_mana = int(character.max_mana or 100)
78
- db_char.perm_move = int(character.max_move or 100)
79
-
80
- # Persist password hash if available on runtime character
81
- if pcdata:
82
- pwd = getattr(pcdata, "pwd", None) or ""
83
- if pwd and not getattr(db_char, "password_hash", ""):
84
- db_char.password_hash = pwd
85
-
86
- db_char.default_weapon_vnum = int(character.default_weapon_vnum or 0)
87
- db_char.creation_points = int(getattr(character, "creation_points", 0) or 0)
88
- db_char.creation_groups = json.dumps(list(getattr(character, "creation_groups", ())))
89
- db_char.newbie_help_seen = bool(getattr(character, "newbie_help_seen", False))
90
- room = getattr(character, "room", None)
91
- was_in_room = getattr(character, "was_in_room", None)
92
- room_vnum = 0
93
- if room is not None:
94
- room_vnum = int(getattr(room, "vnum", 0) or 0)
95
- if room_vnum == int(ROOM_VNUM_LIMBO) and was_in_room is not None:
96
- room_vnum = int(getattr(was_in_room, "vnum", 0) or 0)
97
- elif was_in_room is not None:
98
- room_vnum = int(getattr(was_in_room, "vnum", 0) or 0)
99
- if room_vnum <= 0:
100
- room_vnum = int(ROOM_VNUM_TEMPLE)
101
- db_char.room_vnum = room_vnum
102
- save_objects_for_character(session, character, db_char)
103
- session.commit()
91
+ save_character_to_db(session, character)
92
+ session.commit()
104
93
  except Exception as e:
105
- print(f"[ERROR] Failed to save character {character.name}: {e}")
94
+ print(f"[ERROR] save_character failed for {character.name}: {e}")
106
95
  finally:
107
96
  if session:
108
97
  session.close()
98
+
99
+
100
+ def save_character_to_db(session: Session, character: Character) -> None:
101
+ """Write all character state to the DB row — the canonical DB save path.
102
+
103
+ UPDATE-only: the character row must already exist (created via
104
+ account_service.create_character). If the row is not found, returns silently.
105
+
106
+ Replicates the nontrivial logic from the former mud.persistence.save_character:
107
+ - room_vnum LIMBO → TEMPLE fallback (mirroring ROM src/save.c:fwrite_char)
108
+ - played accumulation: base_played + (now - logon) (ROM src/save.c:fwrite_char)
109
+ - act flags reconciled with ansi_enabled (PlayerFlag.COLOUR bit)
110
+ - All 71 fields from INV008_REVERSAL_AUDIT §1
111
+
112
+ Does NOT commit the session — caller is responsible for session.commit()
113
+ so multiple saves can be batched.
114
+
115
+ ROM Reference: src/save.c fwrite_char / fwrite_pet
116
+ # mirroring mud/persistence.py:save_character
117
+ """
118
+ from mud.models.constants import PlayerFlag
119
+ from mud.notes import DEFAULT_BOARD_NAME
120
+
121
+ db_char = session.query(DBCharacter).filter_by(name=character.name).first()
122
+ if db_char is None:
123
+ return # Character must exist — creation path handles inserts
124
+
125
+ pcdata = character.pcdata or __import__("mud.models.character", fromlist=["PCData"]).PCData()
126
+
127
+ # --- room_vnum: LIMBO fallback → TEMPLE (mirroring persistence.py:913-932) ---
128
+ room = getattr(character, "room", None)
129
+ current_vnum = getattr(room, "vnum", None)
130
+ if current_vnum == ROOM_VNUM_LIMBO:
131
+ was_in_room = getattr(character, "was_in_room", None)
132
+ fallback_vnum = getattr(was_in_room, "vnum", None)
133
+ if fallback_vnum is not None:
134
+ try:
135
+ room_vnum = int(fallback_vnum)
136
+ except (TypeError, ValueError):
137
+ room_vnum = ROOM_VNUM_TEMPLE
138
+ else:
139
+ room_vnum = ROOM_VNUM_TEMPLE
140
+ elif current_vnum is None:
141
+ room_vnum = ROOM_VNUM_TEMPLE
142
+ else:
143
+ try:
144
+ room_vnum = int(current_vnum)
145
+ except (TypeError, ValueError):
146
+ room_vnum = ROOM_VNUM_TEMPLE
147
+
148
+ # --- played accumulation (mirroring persistence.py:935-946) ---
149
+ now = int(time.time())
150
+ try:
151
+ logon_value = int(getattr(character, "logon", 0) or 0)
152
+ except (TypeError, ValueError):
153
+ logon_value = 0
154
+ try:
155
+ base_played = int(getattr(character, "played", 0) or 0)
156
+ except (TypeError, ValueError):
157
+ base_played = 0
158
+ session_played = max(0, now - logon_value) if logon_value else 0
159
+ total_played = max(0, base_played + session_played)
160
+
161
+ # --- act flags: reconcile ANSI colour bit (mirroring persistence.py:903-911) ---
162
+ ansi_enabled = bool(getattr(character, "ansi_enabled", True))
163
+ act_flags = int(getattr(character, "act", 0))
164
+ colour_bit = int(PlayerFlag.COLOUR)
165
+ if ansi_enabled:
166
+ act_flags |= colour_bit
167
+ else:
168
+ act_flags &= ~colour_bit
169
+
170
+ # --- scalar fields ---
171
+ db_char.level = character.level
172
+ db_char.hp = character.hit # column is named hp, field is hit
173
+ db_char.max_hit = character.max_hit
174
+ db_char.mana = character.mana
175
+ db_char.move = character.move
176
+ db_char.room_vnum = room_vnum
177
+ db_char.race = int(getattr(character, "race", 0))
178
+ db_char.ch_class = int(getattr(character, "ch_class", 0))
179
+ db_char.sex = int(getattr(character, "sex", 0))
180
+ db_char.true_sex = int(getattr(pcdata, "true_sex", 0))
181
+ db_char.alignment = int(getattr(character, "alignment", 0))
182
+ db_char.act = act_flags
183
+ db_char.practice = int(getattr(character, "practice", 0))
184
+ db_char.train = int(getattr(character, "train", 0))
185
+ db_char.perm_hit = int(getattr(pcdata, "perm_hit", 0))
186
+ db_char.perm_mana = int(getattr(pcdata, "perm_mana", 0))
187
+ db_char.perm_move = int(getattr(pcdata, "perm_move", 0))
188
+ db_char.gold = int(getattr(character, "gold", 0))
189
+ db_char.silver = int(getattr(character, "silver", 0))
190
+ db_char.exp = int(getattr(character, "exp", 0))
191
+ db_char.trust = int(getattr(character, "trust", 0))
192
+ db_char.invis_level = int(getattr(character, "invis_level", 0))
193
+ db_char.incog_level = int(getattr(character, "incog_level", 0))
194
+ db_char.saving_throw = int(getattr(character, "saving_throw", 0))
195
+ db_char.hitroll = int(getattr(character, "hitroll", 0))
196
+ db_char.damroll = int(getattr(character, "damroll", 0))
197
+ db_char.wimpy = int(getattr(character, "wimpy", 0))
198
+ db_char.position = int(getattr(character, "position", 8))
199
+ db_char.played = total_played
200
+ db_char.logon = logon_value
201
+ db_char.lines = int(getattr(character, "lines", 22))
202
+ db_char.prompt = getattr(character, "prompt", None)
203
+ prefix_value = getattr(character, "prefix", None)
204
+ db_char.prefix = str(prefix_value) if prefix_value is not None else None
205
+ db_char.affected_by = int(getattr(character, "affected_by", 0))
206
+ db_char.comm = int(getattr(character, "comm", 0))
207
+ db_char.wiznet = int(getattr(character, "wiznet", 0))
208
+ db_char.log_commands = bool(getattr(character, "log_commands", False))
209
+ db_char.newbie_help_seen = bool(getattr(character, "newbie_help_seen", False))
210
+ db_char.pfile_version = 1 # TABLES-001: always ROM-canonical on DB path
211
+
212
+ # --- pcdata scalar fields ---
213
+ db_char.title = getattr(pcdata, "title", None)
214
+ bamfin = getattr(pcdata, "bamfin", None)
215
+ db_char.bamfin = str(bamfin) if bamfin is not None else None
216
+ bamfout = getattr(pcdata, "bamfout", None)
217
+ db_char.bamfout = str(bamfout) if bamfout is not None else None
218
+ db_char.security = int(getattr(pcdata, "security", 0))
219
+ db_char.points = int(getattr(pcdata, "points", 0))
220
+ db_char.last_level = int(getattr(pcdata, "last_level", 0))
221
+
222
+ # --- password_hash sync ---
223
+ pwd = getattr(pcdata, "pwd", "") or ""
224
+ if pwd:
225
+ db_char.password_hash = pwd
226
+
227
+ # --- JSON collection fields ---
228
+ # skills: merge char.skills and pcdata.learned (mirroring persistence.py:898-901)
229
+ skills_snapshot = _serialize_skill_map(getattr(character, "skills", {}))
230
+ pcdata.learned = dict(skills_snapshot)
231
+ db_char.skills = skills_snapshot
232
+
233
+ # groups: from pcdata.group_known (mirroring persistence.py:899)
234
+ groups_snapshot = _serialize_groups(getattr(pcdata, "group_known", ()))
235
+ pcdata.group_known = tuple(groups_snapshot)
236
+ db_char.groups = groups_snapshot
237
+
238
+ # colours
239
+ colour_table = _serialize_colour_table(pcdata)
240
+ db_char.colours = colour_table
241
+
242
+ # conditions [drunk, full, thirst, hunger]
243
+ raw_conditions = list(getattr(pcdata, "condition", []))
244
+ conditions = [0, 48, 48, 48]
245
+ for idx, val in enumerate(raw_conditions[:4]):
246
+ try:
247
+ conditions[idx] = int(val)
248
+ except (TypeError, ValueError):
249
+ pass
250
+ db_char.conditions = conditions
251
+
252
+ # armor and mod_stat
253
+ db_char.armor = _normalize_int_list(getattr(character, "armor", []), 4)
254
+ db_char.mod_stat = _normalize_int_list(getattr(character, "mod_stat", []), 5)
255
+
256
+ # aliases
257
+ db_char.aliases = dict(getattr(character, "aliases", {}))
258
+
259
+ # board name
260
+ board_name = getattr(pcdata, "board_name", DEFAULT_BOARD_NAME) or DEFAULT_BOARD_NAME
261
+ db_char.board = board_name
262
+
263
+ # last_notes
264
+ db_char.last_notes = dict(getattr(pcdata, "last_notes", {}) or {})
265
+
266
+ # perm_stats JSON (already stored as string, keep existing column)
267
+ from mud.models.character import _encode_perm_stats
268
+ db_char.perm_stats = _encode_perm_stats(getattr(character, "perm_stat", []))
269
+
270
+ # creation_groups / creation_skills (keep in sync)
271
+ from mud.models.character import _encode_creation_groups, _encode_creation_skills
272
+ db_char.creation_groups = _encode_creation_groups(getattr(character, "creation_groups", ()))
273
+ db_char.creation_skills = _encode_creation_skills(getattr(character, "creation_skills", ()))
274
+ db_char.creation_points = int(getattr(character, "creation_points", 0) or 0)
275
+
276
+ # inventory as JSON blob (Option A from audit §3.1)
277
+ inventory_list = []
278
+ for obj in character.inventory:
279
+ try:
280
+ obj_save = _serialize_object(obj)
281
+ # Convert dataclass to dict for JSON storage
282
+ inventory_list.append(_dataclass_to_dict(obj_save))
283
+ except Exception:
284
+ pass
285
+ db_char.inventory_state = inventory_list
286
+
287
+ # equipment as JSON blob (Option A from audit §3.1)
288
+ equipment_dict = {}
289
+ for slot, obj in character.equipment.items():
290
+ try:
291
+ obj_save = _serialize_object(obj, wear_slot=slot)
292
+ equipment_dict[slot] = _dataclass_to_dict(obj_save)
293
+ except Exception:
294
+ pass
295
+ db_char.equipment_state = equipment_dict
296
+
297
+ # pet as JSON blob (audit §3.1)
298
+ pet = getattr(character, "pet", None)
299
+ if pet is not None:
300
+ try:
301
+ pet_save = _serialize_pet(pet)
302
+ if pet_save is not None:
303
+ db_char.pet_state = _dataclass_to_dict(pet_save)
304
+ else:
305
+ db_char.pet_state = None
306
+ except Exception:
307
+ db_char.pet_state = None
308
+ else:
309
+ db_char.pet_state = None
310
+
311
+
312
+ def _dataclass_to_dict(obj: object) -> dict:
313
+ """Recursively convert a dataclass instance to a plain dict for JSON storage."""
314
+ import dataclasses
315
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
316
+ result = {}
317
+ for f in dataclasses.fields(obj): # type: ignore[arg-type]
318
+ val = getattr(obj, f.name)
319
+ if dataclasses.is_dataclass(val) and not isinstance(val, type):
320
+ result[f.name] = _dataclass_to_dict(val)
321
+ elif isinstance(val, list):
322
+ result[f.name] = [
323
+ _dataclass_to_dict(item) if (dataclasses.is_dataclass(item) and not isinstance(item, type)) else item
324
+ for item in val
325
+ ]
326
+ elif isinstance(val, dict):
327
+ result[f.name] = {
328
+ k: (_dataclass_to_dict(v) if (dataclasses.is_dataclass(v) and not isinstance(v, type)) else v)
329
+ for k, v in val.items()
330
+ }
331
+ else:
332
+ result[f.name] = val
333
+ return result
334
+ return obj # type: ignore[return-value]
@@ -1,46 +1,53 @@
1
1
  import json
2
2
  from collections.abc import Iterable
3
- from functools import lru_cache
4
3
  from dataclasses import dataclass, field
5
4
  from enum import Enum
5
+ from functools import lru_cache
6
6
  from pathlib import Path
7
- from typing import Final, Iterable, NamedTuple
7
+ from typing import Final, NamedTuple
8
8
 
9
+ from mud.advancement import exp_per_level_for_creation
9
10
  from mud.db.models import Character
10
11
  from mud.db.session import SessionLocal
11
12
  from mud.models.classes import (
12
13
  ClassType,
14
+ )
15
+ from mud.models.classes import (
13
16
  get_player_class as _get_player_class,
17
+ )
18
+ from mud.models.classes import (
14
19
  list_player_classes as _list_player_classes,
15
20
  )
16
21
  from mud.models.constants import (
17
- OBJ_VNUM_SCHOOL_DAGGER,
18
- OBJ_VNUM_SCHOOL_MACE,
19
- OBJ_VNUM_SCHOOL_SWORD,
20
- PlayerFlag,
21
22
  ROOM_VNUM_SCHOOL,
23
+ PlayerFlag,
22
24
  Sex,
23
- Stat,
24
25
  )
25
26
  from mud.models.races import (
26
27
  PcRaceType,
27
28
  RaceType,
29
+ )
30
+ from mud.models.races import (
28
31
  get_pc_race as _get_pc_race,
32
+ )
33
+ from mud.models.races import (
29
34
  get_race as _get_race,
35
+ )
36
+ from mud.models.races import (
30
37
  list_playable_races as _list_playable_races,
31
38
  )
39
+ from mud.models.titles import default_title_storage
40
+ from mud.models.weapon_table import weapon_table_entry_by_name
32
41
  from mud.security import bans
33
42
  from mud.security.bans import BanFlag
34
43
  from mud.security.hash_utils import hash_password, verify_password
35
44
  from mud.skills.groups import get_group, iter_group_names, list_groups
36
45
  from mud.skills.metadata import ROM_SKILL_METADATA, ROM_SKILL_NAMES_BY_INDEX
46
+ from mud.utils import rng_mm
37
47
  from mud.world.world_state import (
38
48
  is_newlock_enabled,
39
49
  is_wizlock_enabled,
40
50
  )
41
- from mud.utils import rng_mm
42
-
43
- from mud.advancement import exp_per_level_for_creation
44
51
 
45
52
 
46
53
  class LoginFailureReason(Enum):
@@ -94,13 +101,6 @@ _WEAPON_CHOICES: Final[dict[str, tuple[str, ...]]] = {
94
101
  "warrior": ("sword", "mace"),
95
102
  }
96
103
 
97
- _WEAPON_VNUMS: Final[dict[str, int]] = {
98
- "dagger": OBJ_VNUM_SCHOOL_DAGGER,
99
- "mace": OBJ_VNUM_SCHOOL_MACE,
100
- "sword": OBJ_VNUM_SCHOOL_SWORD,
101
- }
102
-
103
-
104
104
  @lru_cache(maxsize=1)
105
105
  def _load_skill_data() -> dict[str, dict[str, object]]:
106
106
  """Return ROM skill metadata (type/ratings) keyed by lower-case name."""
@@ -563,7 +563,8 @@ def get_weapon_choices(class_type: ClassType) -> tuple[str, ...]:
563
563
  def lookup_weapon_choice(name: str) -> int | None:
564
564
  """Map a weapon name from creation prompts to its object vnum."""
565
565
 
566
- return _WEAPON_VNUMS.get(name.strip().lower())
566
+ entry = weapon_table_entry_by_name(name)
567
+ return entry.school_vnum if entry is not None else None
567
568
 
568
569
 
569
570
  def sanitize_account_name(username: str) -> str:
@@ -1062,8 +1063,12 @@ def create_character(
1062
1063
  "perm_move": 100,
1063
1064
  "act": int(PlayerFlag.NOSUMMON),
1064
1065
  "default_weapon_vnum": weapon_vnum,
1066
+ "title": default_title_storage(_class_index_for(selected_class), 1, sex_value),
1065
1067
  "newbie_help_seen": False,
1066
1068
  "creation_points": int(creation_points_value),
1069
+ # mirroring ROM src/nanny.c: the creation point total is persistent
1070
+ # character state, so keep DB-canonical `points` in sync on initial save.
1071
+ "points": int(creation_points_value),
1067
1072
  "creation_groups": json.dumps(list(groups_tuple)),
1068
1073
  "creation_skills": json.dumps(list(skills_tuple)),
1069
1074
  }
mud/advancement.py CHANGED
@@ -2,12 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  import time
4
4
 
5
+ from mud.logging import log_game_event
5
6
  from mud.math.c_compat import c_div
6
7
  from mud.math.stat_apps import con_hitp_bonus, wis_practice_bonus
7
8
  from mud.models.character import Character
8
9
  from mud.models.classes import CLASS_TABLE, ClassType
9
10
  from mud.models.constants import LEVEL_HERO
10
11
  from mud.models.races import PcRaceType, list_playable_races
12
+ from mud.models.titles import default_title_text
11
13
  from mud.utils import rng_mm
12
14
  from mud.wiznet import WiznetFlag, wiznet
13
15
 
@@ -138,6 +140,9 @@ def advance_level(char: Character) -> None:
138
140
  pcdata.perm_hit = int(getattr(pcdata, "perm_hit", 0) or 0) + hp
139
141
  pcdata.perm_mana = int(getattr(pcdata, "perm_mana", 0) or 0) + mana
140
142
  pcdata.perm_move = int(getattr(pcdata, "perm_move", 0) or 0) + move
143
+ from mud.commands.character import set_title
144
+
145
+ set_title(char, default_title_text(char.ch_class, char.level, getattr(char, "sex", 0)))
141
146
 
142
147
  if hasattr(char, "send_to_char") and not getattr(char, "is_npc", False):
143
148
  hit_suffix = "" if hp == 1 else "s"
@@ -212,10 +217,10 @@ def gain_exp(char: Character, amount: int) -> None:
212
217
 
213
218
  # Level up while total exp meets threshold for next level.
214
219
  while char.level < LEVEL_HERO and char.exp >= exp_per_level(char) * (char.level + 1):
215
- char.level += 1
216
- advance_level(char)
217
220
  if hasattr(char, "send_to_char"):
218
221
  char.send_to_char("{GYou raise a level!! {x")
222
+ char.level += 1
223
+ log_game_event(f"{getattr(char, 'name', 'Someone')} gained level {char.level}")
219
224
  wiznet(
220
225
  f"$N has attained level {char.level}!",
221
226
  char,
@@ -224,6 +229,7 @@ def gain_exp(char: Character, amount: int) -> None:
224
229
  None,
225
230
  0,
226
231
  )
232
+ advance_level(char)
227
233
  # Lazy import to avoid circular dependency
228
234
  from mud.account.account_manager import save_character
229
235
  save_character(char)
mud/affects/engine.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from mud.models.character import Character
4
+ from mud.utils import rng_mm
4
5
 
5
6
  ROM_NEWLINE = "\n\r"
6
7
 
@@ -15,6 +16,59 @@ def tick_spell_effects(character: Character) -> list[str]:
15
16
  if not isinstance(effects, dict):
16
17
  return messages
17
18
 
19
+ affected = getattr(character, "affected", None)
20
+ if isinstance(affected, list) and affected:
21
+ touched_names: set[str] = set()
22
+ ordered_affects = list(affected)
23
+
24
+ for index, affect in enumerate(ordered_affects):
25
+ duration = int(getattr(affect, "duration", 0) or 0)
26
+ if duration > 0:
27
+ affect.duration = duration - 1
28
+ level = int(getattr(affect, "level", 0) or 0)
29
+ if level > 0 and rng_mm.number_range(0, 4) == 0:
30
+ affect.level = level - 1 # mirroring ROM src/update.c:765-768
31
+ spell_name = getattr(affect, "type", None)
32
+ if isinstance(spell_name, str) and spell_name in effects:
33
+ touched_names.add(spell_name)
34
+ continue
35
+ if duration < 0:
36
+ continue
37
+
38
+ spell_name = getattr(affect, "type", None)
39
+ next_affect = ordered_affects[index + 1] if index + 1 < len(ordered_affects) else None
40
+ should_emit = (
41
+ next_affect is None
42
+ or getattr(next_affect, "type", None) != spell_name
43
+ or int(getattr(next_affect, "duration", 0) or 0) > 0
44
+ )
45
+
46
+ if affect in affected:
47
+ affected.remove(affect)
48
+
49
+ if isinstance(spell_name, str) and spell_name in effects:
50
+ touched_names.add(spell_name)
51
+ wear_off = getattr(effects[spell_name], "wear_off_message", None)
52
+ if should_emit and wear_off:
53
+ messages.append(f"{wear_off}{ROM_NEWLINE}")
54
+
55
+ for spell_name in touched_names:
56
+ remaining = [
57
+ affect
58
+ for affect in getattr(character, "affected", [])
59
+ if getattr(affect, "type", None) == spell_name
60
+ ]
61
+ if remaining:
62
+ primary = remaining[0]
63
+ effect = effects.get(spell_name)
64
+ if effect is not None:
65
+ effect.duration = int(getattr(primary, "duration", 0) or 0)
66
+ effect.level = int(getattr(primary, "level", 0) or 0)
67
+ continue
68
+ character.remove_spell_effect(spell_name)
69
+
70
+ return messages
71
+
18
72
  for name, effect in list(effects.items()):
19
73
  duration = int(getattr(effect, "duration", 0) or 0)
20
74
  if duration > 0: