rom24-quickmud-python 2.5.4__py3-none-any.whl → 2.8.21__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 (126) hide show
  1. mud/__init__.py +1 -0
  2. mud/account/__init__.py +16 -2
  3. mud/account/account_manager.py +300 -107
  4. mud/account/account_service.py +296 -169
  5. mud/admin_logging/__init__.py +1 -0
  6. mud/advancement.py +40 -8
  7. mud/affects/engine.py +50 -0
  8. mud/agent/__init__.py +1 -0
  9. mud/ai/__init__.py +2 -55
  10. mud/combat/assist.py +10 -4
  11. mud/combat/engine.py +145 -105
  12. mud/commands/admin_commands.py +85 -90
  13. mud/commands/advancement.py +188 -17
  14. mud/commands/affects.py +34 -1
  15. mud/commands/alias_cmds.py +102 -26
  16. mud/commands/auto_settings.py +143 -18
  17. mud/commands/build.py +2051 -188
  18. mud/commands/character.py +41 -31
  19. mud/commands/combat.py +8 -4
  20. mud/commands/communication.py +66 -24
  21. mud/commands/consumption.py +261 -165
  22. mud/commands/dispatcher.py +456 -198
  23. mud/commands/doors.py +227 -130
  24. mud/commands/equipment.py +281 -165
  25. mud/commands/give.py +217 -168
  26. mud/commands/group_commands.py +255 -150
  27. mud/commands/healer.py +222 -53
  28. mud/commands/imm_admin.py +170 -122
  29. mud/commands/imm_commands.py +94 -68
  30. mud/commands/imm_display.py +198 -151
  31. mud/commands/imm_emote.py +167 -102
  32. mud/commands/imm_load.py +133 -113
  33. mud/commands/imm_olc.py +706 -170
  34. mud/commands/imm_punish.py +154 -151
  35. mud/commands/imm_search.py +850 -306
  36. mud/commands/imm_server.py +122 -106
  37. mud/commands/imm_set.py +490 -397
  38. mud/commands/info.py +4 -2
  39. mud/commands/info_extended.py +1 -76
  40. mud/commands/inspection.py +13 -5
  41. mud/commands/inventory.py +563 -36
  42. mud/commands/liquids.py +138 -63
  43. mud/commands/magic_items.py +296 -384
  44. mud/commands/misc_player.py +3 -3
  45. mud/commands/movement.py +34 -23
  46. mud/commands/notes.py +81 -29
  47. mud/commands/obj_manipulation.py +225 -67
  48. mud/commands/player_info.py +105 -65
  49. mud/commands/position.py +421 -40
  50. mud/commands/remaining_rom.py +271 -240
  51. mud/commands/session.py +89 -88
  52. mud/commands/shop.py +131 -32
  53. mud/commands/socials.py +57 -6
  54. mud/commands/thief_skills.py +324 -234
  55. mud/commands/typo_guards.py +10 -34
  56. mud/db/__init__.py +1 -0
  57. mud/db/migrations.py +78 -0
  58. mud/db/models.py +58 -21
  59. mud/db/seed.py +11 -12
  60. mud/db/serializers.py +622 -0
  61. mud/devtools/__init__.py +1 -0
  62. mud/game_loop.py +242 -41
  63. mud/groups/xp.py +30 -30
  64. mud/handler.py +280 -22
  65. mud/loaders/json_loader.py +311 -29
  66. mud/loaders/mob_loader.py +75 -7
  67. mud/loaders/obj_loader.py +6 -5
  68. mud/magic/effects.py +20 -13
  69. mud/math/stat_apps.py +294 -0
  70. mud/mob_cmds.py +298 -52
  71. mud/mobprog.py +121 -40
  72. mud/models/board.py +26 -2
  73. mud/models/character.py +189 -18
  74. mud/models/clans.py +3 -1
  75. mud/models/constants.py +123 -69
  76. mud/models/mob.py +25 -0
  77. mud/models/object.py +30 -2
  78. mud/models/races.py +17 -0
  79. mud/models/room.py +14 -1
  80. mud/models/social.py +21 -0
  81. mud/models/titles.py +292 -0
  82. mud/models/weapon_table.py +86 -0
  83. mud/music/__init__.py +173 -10
  84. mud/net/ansi.py +54 -8
  85. mud/net/connection.py +508 -128
  86. mud/net/session.py +8 -0
  87. mud/network/__init__.py +1 -0
  88. mud/network/websocket_server.py +38 -68
  89. mud/network/websocket_stream.py +165 -0
  90. mud/notes.py +253 -2
  91. mud/olc/editor_state.py +79 -0
  92. mud/olc/save.py +246 -5
  93. mud/rom_api.py +1 -1
  94. mud/scripts/convert_are_to_json.py +24 -7
  95. mud/scripts/load_test_data.py +6 -13
  96. mud/security/__init__.py +1 -0
  97. mud/security/bans.py +3 -1
  98. mud/skills/handlers.py +43 -3
  99. mud/skills/registry.py +19 -7
  100. mud/spawning/mob_spawner.py +7 -0
  101. mud/spawning/reset_handler.py +11 -12
  102. mud/spawning/templates.py +23 -2
  103. mud/spec_funs.py +142 -64
  104. mud/utils/act.py +9 -5
  105. mud/utils/bit.py +122 -0
  106. mud/utils/fix_sex.py +24 -0
  107. mud/utils/olc_tables.py +127 -0
  108. mud/utils/poses.py +187 -0
  109. mud/utils/prefix_lookup.py +217 -0
  110. mud/utils/prompt.py +259 -0
  111. mud/utils/string_editor.py +581 -0
  112. mud/wiznet.py +119 -46
  113. mud/world/char_find.py +6 -2
  114. mud/world/look.py +20 -6
  115. mud/world/movement.py +138 -50
  116. mud/world/obj_find.py +3 -3
  117. mud/world/time_persistence.py +61 -0
  118. mud/world/world_state.py +17 -0
  119. {rom24_quickmud_python-2.5.4.dist-info → rom24_quickmud_python-2.8.21.dist-info}/METADATA +87 -21
  120. rom24_quickmud_python-2.8.21.dist-info/RECORD +224 -0
  121. {rom24_quickmud_python-2.5.4.dist-info → rom24_quickmud_python-2.8.21.dist-info}/WHEEL +1 -1
  122. mud/persistence.py +0 -1147
  123. rom24_quickmud_python-2.5.4.dist-info/RECORD +0 -211
  124. {rom24_quickmud_python-2.5.4.dist-info → rom24_quickmud_python-2.8.21.dist-info}/entry_points.txt +0 -0
  125. {rom24_quickmud_python-2.5.4.dist-info → rom24_quickmud_python-2.8.21.dist-info}/licenses/LICENSE +0 -0
  126. {rom24_quickmud_python-2.5.4.dist-info → rom24_quickmud_python-2.8.21.dist-info}/top_level.txt +0 -0
mud/__init__.py CHANGED
@@ -0,0 +1 @@
1
+ """Package marker for mud."""
mud/account/__init__.py CHANGED
@@ -1,11 +1,17 @@
1
- """Account management utilities."""
1
+ """Account management utilities.
2
+
3
+ The Python-only PlayerAccount layer has been removed. Characters now own
4
+ their passwords directly, mirroring ROM src/merc.h PCData.pwd.
5
+ """
2
6
 
3
7
  from .account_manager import load_character, save_character
4
8
  from .account_service import (
5
9
  CreationSelection,
6
10
  account_exists,
11
+ character_exists,
7
12
  create_account,
8
13
  create_character,
14
+ create_character_record,
9
15
  clear_active_accounts,
10
16
  get_creation_classes,
11
17
  get_creation_races,
@@ -14,6 +20,8 @@ from .account_service import (
14
20
  get_race_archetype,
15
21
  is_account_active,
16
22
  is_valid_account_name,
23
+ is_valid_character_name,
24
+ login_character,
17
25
  lookup_creation_class,
18
26
  lookup_creation_race,
19
27
  lookup_hometown,
@@ -27,17 +35,21 @@ from .account_service import (
27
35
  finalize_creation_stats,
28
36
  sanitize_account_name,
29
37
  release_account,
38
+ release_character,
30
39
  )
31
40
 
32
41
  __all__ = [
33
42
  "load_character",
34
43
  "save_character",
35
44
  "account_exists",
45
+ "character_exists",
36
46
  "create_account",
47
+ "create_character",
48
+ "create_character_record",
37
49
  "login",
50
+ "login_character",
38
51
  "login_with_host",
39
52
  "list_characters",
40
- "create_character",
41
53
  "CreationSelection",
42
54
  "get_creation_races",
43
55
  "lookup_creation_race",
@@ -51,10 +63,12 @@ __all__ = [
51
63
  "roll_creation_stats",
52
64
  "finalize_creation_stats",
53
65
  "is_valid_account_name",
66
+ "is_valid_character_name",
54
67
  "sanitize_account_name",
55
68
  "clear_active_accounts",
56
69
  "is_account_active",
57
70
  "release_account",
71
+ "release_character",
58
72
  "LoginFailureReason",
59
73
  "LoginResult",
60
74
  ]
@@ -1,38 +1,69 @@
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
- from mud.db.models import PlayerAccount
7
27
  from mud.db.session import SessionLocal
8
- 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
9
32
  from mud.models.constants import ROOM_VNUM_LIMBO, ROOM_VNUM_TEMPLE
10
- from mud.models.conversion import (
11
- load_objects_for_character,
12
- 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,
13
40
  )
14
41
 
15
42
 
16
- def load_character(username: str, char_name: str) -> Character | None:
43
+ def load_character(char_name: str, _ignored: str | None = None) -> Character | None:
44
+ """Load a character by name from the DB row (DB-canonical path).
45
+
46
+ The second argument is accepted but ignored for backward compatibility;
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).
51
+ """
17
52
  session = None
18
53
  try:
19
54
  session = SessionLocal()
20
- db_char = (
21
- session.query(DBCharacter)
22
- .join(PlayerAccount)
23
- .filter(
24
- DBCharacter.name == char_name,
25
- PlayerAccount.username == username,
26
- )
27
- .first()
28
- )
29
- char = from_orm(db_char) if db_char else None
30
- if char and db_char:
31
- _ = db_char.player # load relationship
32
- char.inventory, char.equipment = load_objects_for_character(db_char)
55
+ db_char = session.query(DBCharacter).filter(DBCharacter.name == char_name).first()
56
+ if db_char is None:
57
+ return None
58
+ char = from_orm(db_char)
59
+ if char is not None:
60
+ # Use identity check to avoid triggering dataclass __eq__ on deep
61
+ # object graphs (inventory/equipment items cause recursion with list `in`)
62
+ if not any(c is char for c in character_registry):
63
+ character_registry.append(char) # INV-003
33
64
  return char
34
65
  except Exception as e:
35
- print(f"[ERROR] Failed to load character {char_name}: {e}")
66
+ print(f"[ERROR] Failed to load character {char_name} from DB: {e}")
36
67
  return None
37
68
  finally:
38
69
  if session:
@@ -40,96 +71,258 @@ def load_character(username: str, char_name: str) -> Character | None:
40
71
 
41
72
 
42
73
  def save_character(character: Character) -> None:
74
+ """Persist ``character`` to the DB row (DB-canonical path).
75
+
76
+ UPDATE-only: the character row must already exist (created via
77
+ account_service.create_character). If the row is not found, returns silently.
78
+
79
+ ROM Reference: src/save.c fwrite_char — all fields persisted.
80
+ # mirroring mud/persistence.py:save_character (now removed for JSON path)
81
+ """
43
82
  session = None
44
83
  try:
45
84
  session = SessionLocal()
46
- db_char = session.query(DBCharacter).filter_by(name=character.name).first()
47
- if not db_char:
48
- # Character doesn't exist in database - create it
49
- # This handles cases where character was created via JSON or other means
50
- print(f"[WARN] Character '{character.name}' not found in database, creating new record")
51
-
52
- # CRITICAL: Try to find and link the player account
53
- player_id = None
54
- pcdata = getattr(character, "pcdata", None)
55
- if pcdata:
56
- account_name = getattr(pcdata, "account_name", None)
57
- if account_name:
58
- player_account = session.query(PlayerAccount).filter_by(username=account_name).first()
59
- if player_account:
60
- player_id = player_account.id
61
- print(f"[INFO] Linked character '{character.name}' to account '{account_name}' (id={player_id})")
62
- else:
63
- print(f"[WARN] Could not find player account '{account_name}' for character '{character.name}'")
64
-
65
- db_char = DBCharacter(name=character.name, player_id=player_id)
66
- session.add(db_char)
67
-
68
- # Update all fields
69
- if db_char:
70
- # Ensure player_id is set if we have account information
71
- if not db_char.player_id:
72
- pcdata = getattr(character, "pcdata", None)
73
- if pcdata:
74
- account_name = getattr(pcdata, "account_name", None)
75
- if account_name:
76
- player_account = session.query(PlayerAccount).filter_by(username=account_name).first()
77
- if player_account:
78
- db_char.player_id = player_account.id
79
- print(f"[INFO] Fixed missing player_id for character '{character.name}' -> account '{account_name}'")
80
-
81
- db_char.level = character.level
82
- db_char.hp = character.hit
83
- db_char.race = int(character.race or 0)
84
- db_char.ch_class = int(character.ch_class or 0)
85
- pcdata = getattr(character, "pcdata", None)
86
- true_sex_value = int(getattr(pcdata, "true_sex", getattr(character, "sex", 0)) or 0)
87
- db_char.true_sex = true_sex_value
88
- db_char.sex = int(character.sex or true_sex_value or 0)
89
- db_char.alignment = int(character.alignment or 0)
90
- db_char.act = int(getattr(character, "act", 0) or 0)
91
- db_char.hometown_vnum = int(character.hometown_vnum or 0)
92
- db_char.perm_stats = json.dumps([int(val) for val in character.perm_stat])
93
- db_char.size = int(character.size or 0)
94
- db_char.form = int(character.form or 0)
95
- db_char.parts = int(character.parts or 0)
96
- db_char.imm_flags = int(character.imm_flags or 0)
97
- db_char.res_flags = int(character.res_flags or 0)
98
- db_char.vuln_flags = int(character.vuln_flags or 0)
99
- db_char.practice = int(character.practice or 0)
100
- db_char.train = int(character.train or 0)
101
-
102
- # Save perm stats from pcdata (ROM src/handler.c stores perm_hit/perm_mana/perm_move)
103
- if pcdata:
104
- db_char.perm_hit = int(getattr(pcdata, "perm_hit", character.max_hit or 20))
105
- db_char.perm_mana = int(getattr(pcdata, "perm_mana", character.max_mana or 100))
106
- db_char.perm_move = int(getattr(pcdata, "perm_move", character.max_move or 100))
107
- else:
108
- # Fallback if no pcdata
109
- db_char.perm_hit = int(character.max_hit or 20)
110
- db_char.perm_mana = int(character.max_mana or 100)
111
- db_char.perm_move = int(character.max_move or 100)
112
-
113
- db_char.default_weapon_vnum = int(character.default_weapon_vnum or 0)
114
- db_char.creation_points = int(getattr(character, "creation_points", 0) or 0)
115
- db_char.creation_groups = json.dumps(list(getattr(character, "creation_groups", ())))
116
- db_char.newbie_help_seen = bool(getattr(character, "newbie_help_seen", False))
117
- room = getattr(character, "room", None)
118
- was_in_room = getattr(character, "was_in_room", None)
119
- room_vnum = 0
120
- if room is not None:
121
- room_vnum = int(getattr(room, "vnum", 0) or 0)
122
- if room_vnum == int(ROOM_VNUM_LIMBO) and was_in_room is not None:
123
- room_vnum = int(getattr(was_in_room, "vnum", 0) or 0)
124
- elif was_in_room is not None:
125
- room_vnum = int(getattr(was_in_room, "vnum", 0) or 0)
126
- if room_vnum <= 0:
127
- room_vnum = int(ROOM_VNUM_TEMPLE)
128
- db_char.room_vnum = room_vnum
129
- save_objects_for_character(session, character, db_char)
130
- session.commit()
85
+ save_character_to_db(session, character)
86
+ session.commit()
131
87
  except Exception as e:
132
- print(f"[ERROR] Failed to save character {character.name}: {e}")
88
+ print(f"[ERROR] save_character failed for {character.name}: {e}")
133
89
  finally:
134
90
  if session:
135
91
  session.close()
92
+
93
+
94
+ def save_character_to_db(session: Session, character: Character) -> None:
95
+ """Write all character state to the DB row — the canonical DB save path.
96
+
97
+ UPDATE-only: the character row must already exist (created via
98
+ account_service.create_character). If the row is not found, returns silently.
99
+
100
+ Replicates the nontrivial logic from the former mud.persistence.save_character:
101
+ - room_vnum LIMBO → TEMPLE fallback (mirroring ROM src/save.c:fwrite_char)
102
+ - played accumulation: base_played + (now - logon) (ROM src/save.c:fwrite_char)
103
+ - act flags reconciled with ansi_enabled (PlayerFlag.COLOUR bit)
104
+ - All 71 fields from INV008_REVERSAL_AUDIT §1
105
+
106
+ Does NOT commit the session — caller is responsible for session.commit()
107
+ so multiple saves can be batched.
108
+
109
+ ROM Reference: src/save.c fwrite_char / fwrite_pet
110
+ # mirroring mud/persistence.py:save_character
111
+ """
112
+ from mud.models.constants import PlayerFlag
113
+ from mud.notes import DEFAULT_BOARD_NAME
114
+
115
+ db_char = session.query(DBCharacter).filter_by(name=character.name).first()
116
+ if db_char is None:
117
+ return # Character must exist — creation path handles inserts
118
+
119
+ pcdata = character.pcdata or __import__("mud.models.character", fromlist=["PCData"]).PCData()
120
+
121
+ # --- room_vnum: LIMBO fallback → TEMPLE (mirroring persistence.py:913-932) ---
122
+ room = getattr(character, "room", None)
123
+ current_vnum = getattr(room, "vnum", None)
124
+ if current_vnum == ROOM_VNUM_LIMBO:
125
+ was_in_room = getattr(character, "was_in_room", None)
126
+ fallback_vnum = getattr(was_in_room, "vnum", None)
127
+ if fallback_vnum is not None:
128
+ try:
129
+ room_vnum = int(fallback_vnum)
130
+ except (TypeError, ValueError):
131
+ room_vnum = ROOM_VNUM_TEMPLE
132
+ else:
133
+ room_vnum = ROOM_VNUM_TEMPLE
134
+ elif current_vnum is None:
135
+ room_vnum = ROOM_VNUM_TEMPLE
136
+ else:
137
+ try:
138
+ room_vnum = int(current_vnum)
139
+ except (TypeError, ValueError):
140
+ room_vnum = ROOM_VNUM_TEMPLE
141
+
142
+ # --- played accumulation (mirroring persistence.py:935-946) ---
143
+ now = int(time.time())
144
+ try:
145
+ logon_value = int(getattr(character, "logon", 0) or 0)
146
+ except (TypeError, ValueError):
147
+ logon_value = 0
148
+ try:
149
+ base_played = int(getattr(character, "played", 0) or 0)
150
+ except (TypeError, ValueError):
151
+ base_played = 0
152
+ session_played = max(0, now - logon_value) if logon_value else 0
153
+ total_played = max(0, base_played + session_played)
154
+
155
+ # --- act flags: reconcile ANSI colour bit (mirroring persistence.py:903-911) ---
156
+ ansi_enabled = bool(getattr(character, "ansi_enabled", True))
157
+ act_flags = int(getattr(character, "act", 0))
158
+ colour_bit = int(PlayerFlag.COLOUR)
159
+ if ansi_enabled:
160
+ act_flags |= colour_bit
161
+ else:
162
+ act_flags &= ~colour_bit
163
+
164
+ # --- scalar fields ---
165
+ db_char.level = character.level
166
+ db_char.hp = character.hit # column is named hp, field is hit
167
+ db_char.max_hit = character.max_hit
168
+ db_char.mana = character.mana
169
+ db_char.move = character.move
170
+ db_char.room_vnum = room_vnum
171
+ db_char.race = int(getattr(character, "race", 0))
172
+ db_char.ch_class = int(getattr(character, "ch_class", 0))
173
+ db_char.sex = int(getattr(character, "sex", 0))
174
+ db_char.true_sex = int(getattr(pcdata, "true_sex", 0))
175
+ db_char.alignment = int(getattr(character, "alignment", 0))
176
+ db_char.act = act_flags
177
+ db_char.practice = int(getattr(character, "practice", 0))
178
+ db_char.train = int(getattr(character, "train", 0))
179
+ db_char.perm_hit = int(getattr(pcdata, "perm_hit", 0))
180
+ db_char.perm_mana = int(getattr(pcdata, "perm_mana", 0))
181
+ db_char.perm_move = int(getattr(pcdata, "perm_move", 0))
182
+ db_char.gold = int(getattr(character, "gold", 0))
183
+ db_char.silver = int(getattr(character, "silver", 0))
184
+ db_char.exp = int(getattr(character, "exp", 0))
185
+ db_char.trust = int(getattr(character, "trust", 0))
186
+ db_char.invis_level = int(getattr(character, "invis_level", 0))
187
+ db_char.incog_level = int(getattr(character, "incog_level", 0))
188
+ db_char.saving_throw = int(getattr(character, "saving_throw", 0))
189
+ db_char.hitroll = int(getattr(character, "hitroll", 0))
190
+ db_char.damroll = int(getattr(character, "damroll", 0))
191
+ db_char.wimpy = int(getattr(character, "wimpy", 0))
192
+ db_char.position = int(getattr(character, "position", 8))
193
+ db_char.played = total_played
194
+ db_char.logon = logon_value
195
+ db_char.lines = int(getattr(character, "lines", 22))
196
+ db_char.prompt = getattr(character, "prompt", None)
197
+ prefix_value = getattr(character, "prefix", None)
198
+ db_char.prefix = str(prefix_value) if prefix_value is not None else None
199
+ db_char.affected_by = int(getattr(character, "affected_by", 0))
200
+ db_char.comm = int(getattr(character, "comm", 0))
201
+ db_char.wiznet = int(getattr(character, "wiznet", 0))
202
+ db_char.log_commands = bool(getattr(character, "log_commands", False))
203
+ db_char.newbie_help_seen = bool(getattr(character, "newbie_help_seen", False))
204
+ db_char.pfile_version = 1 # TABLES-001: always ROM-canonical on DB path
205
+
206
+ # --- pcdata scalar fields ---
207
+ db_char.title = getattr(pcdata, "title", None)
208
+ bamfin = getattr(pcdata, "bamfin", None)
209
+ db_char.bamfin = str(bamfin) if bamfin is not None else None
210
+ bamfout = getattr(pcdata, "bamfout", None)
211
+ db_char.bamfout = str(bamfout) if bamfout is not None else None
212
+ db_char.security = int(getattr(pcdata, "security", 0))
213
+ db_char.points = int(getattr(pcdata, "points", 0))
214
+ db_char.last_level = int(getattr(pcdata, "last_level", 0))
215
+
216
+ # --- password_hash sync ---
217
+ pwd = getattr(pcdata, "pwd", "") or ""
218
+ if pwd:
219
+ db_char.password_hash = pwd
220
+
221
+ # --- JSON collection fields ---
222
+ # skills: merge char.skills and pcdata.learned (mirroring persistence.py:898-901)
223
+ skills_snapshot = _serialize_skill_map(getattr(character, "skills", {}))
224
+ pcdata.learned = dict(skills_snapshot)
225
+ db_char.skills = skills_snapshot
226
+
227
+ # groups: from pcdata.group_known (mirroring persistence.py:899)
228
+ groups_snapshot = _serialize_groups(getattr(pcdata, "group_known", ()))
229
+ pcdata.group_known = tuple(groups_snapshot)
230
+ db_char.groups = groups_snapshot
231
+
232
+ # colours
233
+ colour_table = _serialize_colour_table(pcdata)
234
+ db_char.colours = colour_table
235
+
236
+ # conditions [drunk, full, thirst, hunger]
237
+ raw_conditions = list(getattr(pcdata, "condition", []))
238
+ conditions = [0, 48, 48, 48]
239
+ for idx, val in enumerate(raw_conditions[:4]):
240
+ try:
241
+ conditions[idx] = int(val)
242
+ except (TypeError, ValueError):
243
+ pass
244
+ db_char.conditions = conditions
245
+
246
+ # armor and mod_stat
247
+ db_char.armor = _normalize_int_list(getattr(character, "armor", []), 4)
248
+ db_char.mod_stat = _normalize_int_list(getattr(character, "mod_stat", []), 5)
249
+
250
+ # aliases
251
+ db_char.aliases = dict(getattr(character, "aliases", {}))
252
+
253
+ # board name
254
+ board_name = getattr(pcdata, "board_name", DEFAULT_BOARD_NAME) or DEFAULT_BOARD_NAME
255
+ db_char.board = board_name
256
+
257
+ # last_notes
258
+ db_char.last_notes = dict(getattr(pcdata, "last_notes", {}) or {})
259
+
260
+ # perm_stats JSON (already stored as string, keep existing column)
261
+ from mud.models.character import _encode_perm_stats
262
+ db_char.perm_stats = _encode_perm_stats(getattr(character, "perm_stat", []))
263
+
264
+ # creation_groups / creation_skills (keep in sync)
265
+ from mud.models.character import _encode_creation_groups, _encode_creation_skills
266
+ db_char.creation_groups = _encode_creation_groups(getattr(character, "creation_groups", ()))
267
+ db_char.creation_skills = _encode_creation_skills(getattr(character, "creation_skills", ()))
268
+ db_char.creation_points = int(getattr(character, "creation_points", 0) or 0)
269
+
270
+ # inventory as JSON blob (Option A from audit §3.1)
271
+ inventory_list = []
272
+ for obj in character.inventory:
273
+ try:
274
+ obj_save = _serialize_object(obj)
275
+ # Convert dataclass to dict for JSON storage
276
+ inventory_list.append(_dataclass_to_dict(obj_save))
277
+ except Exception:
278
+ pass
279
+ db_char.inventory_state = inventory_list
280
+
281
+ # equipment as JSON blob (Option A from audit §3.1)
282
+ equipment_dict = {}
283
+ for slot, obj in character.equipment.items():
284
+ try:
285
+ obj_save = _serialize_object(obj, wear_slot=slot)
286
+ equipment_dict[slot] = _dataclass_to_dict(obj_save)
287
+ except Exception:
288
+ pass
289
+ db_char.equipment_state = equipment_dict
290
+
291
+ # pet as JSON blob (audit §3.1)
292
+ pet = getattr(character, "pet", None)
293
+ if pet is not None:
294
+ try:
295
+ pet_save = _serialize_pet(pet)
296
+ if pet_save is not None:
297
+ db_char.pet_state = _dataclass_to_dict(pet_save)
298
+ else:
299
+ db_char.pet_state = None
300
+ except Exception:
301
+ db_char.pet_state = None
302
+ else:
303
+ db_char.pet_state = None
304
+
305
+
306
+ def _dataclass_to_dict(obj: object) -> dict:
307
+ """Recursively convert a dataclass instance to a plain dict for JSON storage."""
308
+ import dataclasses
309
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
310
+ result = {}
311
+ for f in dataclasses.fields(obj): # type: ignore[arg-type]
312
+ val = getattr(obj, f.name)
313
+ if dataclasses.is_dataclass(val) and not isinstance(val, type):
314
+ result[f.name] = _dataclass_to_dict(val)
315
+ elif isinstance(val, list):
316
+ result[f.name] = [
317
+ _dataclass_to_dict(item) if (dataclasses.is_dataclass(item) and not isinstance(item, type)) else item
318
+ for item in val
319
+ ]
320
+ elif isinstance(val, dict):
321
+ result[f.name] = {
322
+ k: (_dataclass_to_dict(v) if (dataclasses.is_dataclass(v) and not isinstance(v, type)) else v)
323
+ for k, v in val.items()
324
+ }
325
+ else:
326
+ result[f.name] = val
327
+ return result
328
+ return obj # type: ignore[return-value]