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/models/social.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .social_json import SocialJson
6
+ from mud.models.constants import Sex
7
+
8
+
9
+ @dataclass
10
+ class Social:
11
+ """Runtime representation of a social command."""
12
+
13
+ name: str
14
+ char_no_arg: str = ""
15
+ others_no_arg: str = ""
16
+ char_found: str = ""
17
+ others_found: str = ""
18
+ vict_found: str = ""
19
+ not_found: str = ""
20
+ char_auto: str = ""
21
+ others_auto: str = ""
22
+
23
+ @classmethod
24
+ def from_json(cls, data: SocialJson) -> "Social":
25
+ return cls(**data.to_dict())
26
+
27
+
28
+ # placeholder registry to track loaded socials
29
+ social_registry: dict[str, Social] = {}
30
+
31
+
32
+ def register_social(social: Social) -> None:
33
+ """Register a social by its lowercase name."""
34
+ social_registry[social.name.lower()] = social
35
+
36
+
37
+ # START socials
38
+ def expand_placeholders(message: str, actor: object, victim: object | None = None) -> str:
39
+ """Replace basic ROM placeholders in social messages."""
40
+ def subj(sex: Sex | object) -> str:
41
+ if isinstance(sex, Sex):
42
+ return {Sex.MALE: "he", Sex.FEMALE: "she", Sex.NONE: "it"}.get(sex, "they")
43
+ return "they"
44
+
45
+ def obj(sex: Sex | object) -> str:
46
+ if isinstance(sex, Sex):
47
+ return {Sex.MALE: "him", Sex.FEMALE: "her", Sex.NONE: "it"}.get(sex, "them")
48
+ return "them"
49
+
50
+ def poss(sex: Sex | object) -> str:
51
+ if isinstance(sex, Sex):
52
+ return {Sex.MALE: "his", Sex.FEMALE: "her", Sex.NONE: "its"}.get(sex, "their")
53
+ return "their"
54
+
55
+ # Names
56
+ result = message.replace("$n", getattr(actor, "name", ""))
57
+ if victim is not None:
58
+ result = result.replace("$N", getattr(victim, "name", ""))
59
+
60
+ # Actor pronouns: replace $mself before $m to avoid overlap
61
+ asex = getattr(actor, "sex", None)
62
+ result = result.replace(
63
+ "$mself",
64
+ {Sex.MALE: "himself", Sex.FEMALE: "herself", Sex.NONE: "itself"}.get(asex, "themselves"),
65
+ )
66
+ result = result.replace("$e", subj(asex))
67
+ result = result.replace("$m", obj(asex))
68
+ result = result.replace("$s", poss(asex))
69
+
70
+ # Victim pronouns: $E $M $S
71
+ if victim is not None:
72
+ vsex = getattr(victim, "sex", None)
73
+ result = result.replace("$E", subj(vsex))
74
+ result = result.replace("$M", obj(vsex))
75
+ result = result.replace("$S", poss(vsex))
76
+
77
+ return result
78
+ # END socials
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .json_io import JsonDataclass
6
+
7
+
8
+ @dataclass
9
+ class SocialJson(JsonDataclass):
10
+ """Social command messages matching ``schemas/social.schema.json``."""
11
+
12
+ name: str
13
+ char_no_arg: str = ""
14
+ others_no_arg: str = ""
15
+ char_found: str = ""
16
+ others_found: str = ""
17
+ vict_found: str = ""
18
+ not_found: str = ""
19
+ char_auto: str = ""
20
+ others_auto: str = ""
mud/net/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from .session import Session, SESSIONS
2
+ from .protocol import send_to_char, broadcast_room
3
+
4
+ __all__ = [
5
+ "Session",
6
+ "SESSIONS",
7
+ "send_to_char",
8
+ "broadcast_room",
9
+ ]
mud/net/ansi.py ADDED
@@ -0,0 +1,27 @@
1
+ """ANSI color code translation for ROM-style tokens."""
2
+ from __future__ import annotations
3
+
4
+ ANSI_CODES: dict[str, str] = {
5
+ "{x": "\x1b[0m",
6
+ "{r": "\x1b[31m",
7
+ "{g": "\x1b[32m",
8
+ "{y": "\x1b[33m",
9
+ "{b": "\x1b[34m",
10
+ "{m": "\x1b[35m",
11
+ "{c": "\x1b[36m",
12
+ "{w": "\x1b[37m",
13
+ "{R": "\x1b[1;31m",
14
+ "{G": "\x1b[1;32m",
15
+ "{Y": "\x1b[1;33m",
16
+ "{B": "\x1b[1;34m",
17
+ "{M": "\x1b[1;35m",
18
+ "{C": "\x1b[1;36m",
19
+ "{W": "\x1b[1;37m",
20
+ }
21
+
22
+
23
+ def translate_ansi(text: str) -> str:
24
+ """Replace ROM color tokens with ANSI escape sequences."""
25
+ for token, code in ANSI_CODES.items():
26
+ text = text.replace(token, code)
27
+ return text
mud/net/connection.py ADDED
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+
4
+ from mud.account import (
5
+ load_character,
6
+ save_character,
7
+ create_account,
8
+ login_with_host,
9
+ list_characters,
10
+ create_character,
11
+ )
12
+ from mud.commands import process_command
13
+ from mud.net.session import Session, SESSIONS
14
+ from mud.net.protocol import send_to_char
15
+
16
+
17
+ async def handle_connection(
18
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter
19
+ ) -> None:
20
+ addr = writer.get_extra_info("peername")
21
+ host_for_ban = None
22
+ if isinstance(addr, tuple) and addr:
23
+ host_for_ban = addr[0]
24
+ session = None
25
+ char = None
26
+ account = None
27
+ username = ""
28
+
29
+ try:
30
+ writer.write(b"Welcome to PythonMUD\r\n")
31
+ await writer.drain()
32
+
33
+ # Account login / creation
34
+ while not account:
35
+ writer.write(b"Account: ")
36
+ await writer.drain()
37
+ name_data = await reader.readline()
38
+ if not name_data:
39
+ return
40
+ username = name_data.decode().strip()
41
+ writer.write(b"Password: ")
42
+ await writer.drain()
43
+ pwd_data = await reader.readline()
44
+ if not pwd_data:
45
+ return
46
+ password = pwd_data.decode().strip()
47
+ # Enforce site/account bans at login time
48
+ account = login_with_host(username, password, host_for_ban)
49
+ if not account:
50
+ if create_account(username, password):
51
+ account = login_with_host(username, password, host_for_ban)
52
+ else:
53
+ writer.write(b"Login failed.\r\n")
54
+ await writer.drain()
55
+
56
+ # Character selection / creation
57
+ chars = list_characters(account)
58
+ if chars:
59
+ writer.write(
60
+ ("Characters: " + ", ".join(chars) + "\r\n").encode()
61
+ )
62
+ writer.write(b"Character: ")
63
+ await writer.drain()
64
+ char_data = await reader.readline()
65
+ if not char_data:
66
+ return
67
+ char_name = char_data.decode().strip()
68
+ if char_name not in chars:
69
+ create_character(account, char_name)
70
+ try:
71
+ char = load_character(username, char_name)
72
+ except Exception as e:
73
+ print(f"[ERROR] Failed to load character {char_name}: {e}")
74
+ return
75
+ if char and char.room:
76
+ try:
77
+ char.room.add_character(char)
78
+ except Exception as e:
79
+ print(f"[ERROR] Failed to add character to room: {e}")
80
+
81
+ char.connection = writer
82
+
83
+ session = Session(
84
+ name=char.name or "",
85
+ character=char,
86
+ reader=reader,
87
+ writer=writer,
88
+ )
89
+ SESSIONS[session.name] = session
90
+ print(f"[CONNECT] {addr} as {session.name}")
91
+
92
+ # Send initial room description and prompt
93
+ try:
94
+ if char and char.room:
95
+ response = process_command(char, "look")
96
+ await send_to_char(char, response)
97
+ else:
98
+ await send_to_char(char, "You are floating in a void...")
99
+ except Exception as e:
100
+ print(f"[ERROR] Failed to send initial look: {e}")
101
+ await send_to_char(char, "Welcome to the world!")
102
+
103
+ # Main command loop with error handling
104
+ while True:
105
+ try:
106
+ writer.write(b"> ")
107
+ await writer.drain()
108
+ data = await reader.readline()
109
+ if not data:
110
+ break
111
+ command = data.decode().strip()
112
+ if not command:
113
+ continue
114
+
115
+ try:
116
+ response = process_command(char, command)
117
+ await send_to_char(char, response)
118
+ except Exception as e:
119
+ print(
120
+ "[ERROR] Command processing failed for "
121
+ f"'{command}': {e}"
122
+ )
123
+ await send_to_char(
124
+ char,
125
+ "Sorry, there was an error processing that "
126
+ "command.",
127
+ )
128
+
129
+ # flush broadcast messages queued on character
130
+ while char and char.messages:
131
+ try:
132
+ msg = char.messages.pop(0)
133
+ await send_to_char(char, msg)
134
+ except Exception as e:
135
+ print(f"[ERROR] Failed to send message: {e}")
136
+ break
137
+
138
+ except asyncio.CancelledError:
139
+ break
140
+ except Exception as e:
141
+ print(
142
+ "[ERROR] Connection loop error for "
143
+ f"{session.name if session else 'unknown'}: {e}"
144
+ )
145
+ break
146
+
147
+ except Exception as e:
148
+ print(f"[ERROR] Connection handler error for {addr}: {e}")
149
+ finally:
150
+ # Cleanup with error handling
151
+ try:
152
+ if char:
153
+ save_character(char)
154
+ except Exception as e:
155
+ print(f"[ERROR] Failed to save character: {e}")
156
+
157
+ try:
158
+ if char and char.room:
159
+ char.room.remove_character(char)
160
+ except Exception as e:
161
+ print(f"[ERROR] Failed to remove character from room: {e}")
162
+
163
+ if session and session.name in SESSIONS:
164
+ SESSIONS.pop(session.name, None)
165
+
166
+ try:
167
+ writer.close()
168
+ await writer.wait_closed()
169
+ except Exception as e:
170
+ print(f"[ERROR] Failed to close connection: {e}")
171
+
172
+ print(
173
+ f"[DISCONNECT] {addr} as {session.name if session else 'unknown'}"
174
+ )
mud/net/protocol.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ from typing import Iterable, Optional
4
+
5
+ from mud.models.character import Character, character_registry
6
+ from mud.net.ansi import translate_ansi
7
+
8
+
9
+ async def send_to_char(char: Character, message: str | Iterable[str]) -> None:
10
+ """Send message to character's connection with CRLF."""
11
+ writer = getattr(char, "connection", None)
12
+ if writer is None:
13
+ return
14
+
15
+ if isinstance(message, (list, tuple)):
16
+ text = "\r\n".join(str(m) for m in message)
17
+ else:
18
+ text = str(message)
19
+ text = translate_ansi(text)
20
+
21
+ if not text.endswith("\r\n"):
22
+ text += "\r\n"
23
+ writer.write(text.encode())
24
+ await writer.drain()
25
+
26
+
27
+ def broadcast_room(
28
+ room,
29
+ message: str,
30
+ exclude: Optional[Character] = None,
31
+ ) -> None:
32
+ for char in list(getattr(room, "people", [])):
33
+ if char is exclude:
34
+ continue
35
+ writer = getattr(char, "connection", None)
36
+ if writer:
37
+ # fire and forget
38
+ asyncio.create_task(send_to_char(char, message))
39
+ if hasattr(char, "messages"):
40
+ char.messages.append(message)
41
+
42
+
43
+ def broadcast_global(
44
+ message: str,
45
+ channel: str,
46
+ exclude: Optional[Character] = None,
47
+ ) -> None:
48
+ for char in list(character_registry):
49
+ if char is exclude:
50
+ continue
51
+ if channel in getattr(char, "muted_channels", set()):
52
+ continue
53
+ writer = getattr(char, "connection", None)
54
+ if writer:
55
+ asyncio.create_task(send_to_char(char, message))
56
+ if hasattr(char, "messages"):
57
+ char.messages.append(message)
mud/net/session.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Dict
4
+ import asyncio
5
+
6
+ from mud.models.character import Character
7
+
8
+
9
+ @dataclass
10
+ class Session:
11
+ name: str
12
+ character: Character
13
+ reader: asyncio.StreamReader
14
+ writer: asyncio.StreamWriter
15
+
16
+
17
+ SESSIONS: Dict[str, Session] = {}
18
+
19
+
20
+ def get_online_players() -> list[Character]:
21
+ return [sess.character for sess in SESSIONS.values()]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+
4
+ from mud.world.world_state import initialize_world
5
+ from mud.db.migrations import run_migrations
6
+ from .connection import handle_connection
7
+
8
+
9
+ async def create_server(
10
+ host: str = "0.0.0.0", port: int = 4000, area_list: str = "area/area.lst"
11
+ ) -> asyncio.AbstractServer:
12
+ """Return a started telnet server without blocking the loop."""
13
+ # Initialize database tables
14
+ run_migrations()
15
+ # Initialize world data
16
+ initialize_world(area_list)
17
+ return await asyncio.start_server(handle_connection, host, port)
18
+
19
+
20
+ async def start_server(
21
+ host: str = "0.0.0.0", port: int = 4000, area_list: str = "area/area.lst"
22
+ ) -> None:
23
+ server = await create_server(host, port, area_list)
24
+ addr = server.sockets[0].getsockname()
25
+ print(f"Serving on {addr}")
26
+ async with server:
27
+ await server.serve_forever()
28
+
29
+
30
+ if __name__ == "__main__":
31
+ asyncio.run(start_server())
File without changes
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ import uvicorn
5
+
6
+ from mud.config import HOST, PORT, CORS_ORIGINS
7
+ from mud.world.world_state import initialize_world, create_test_character
8
+ from mud.account import load_character, save_character
9
+ from mud.commands import process_command
10
+ from .websocket_session import WebSocketPlayerSession
11
+
12
+ app = FastAPI()
13
+
14
+ app.add_middleware(
15
+ CORSMiddleware,
16
+ allow_origins=CORS_ORIGINS,
17
+ allow_credentials=True,
18
+ allow_methods=["*"],
19
+ allow_headers=["*"],
20
+ )
21
+
22
+
23
+ @app.on_event("startup")
24
+ async def startup() -> None:
25
+ initialize_world(None)
26
+
27
+
28
+ @app.websocket("/ws")
29
+ async def websocket_endpoint(websocket: WebSocket):
30
+ await websocket.accept()
31
+ await websocket.send_json({"type": "info", "text": "Welcome to PythonMUD. What is your name?"})
32
+ try:
33
+ data = await websocket.receive_json()
34
+ except WebSocketDisconnect:
35
+ return
36
+ name = data.get("text", "guest")
37
+ char = load_character(name, name)
38
+ if not char:
39
+ char = create_test_character(name, 3001)
40
+ elif char.room:
41
+ char.room.add_character(char)
42
+
43
+ session = WebSocketPlayerSession(websocket=websocket, character=char, name=name)
44
+ char.connection = session
45
+
46
+ try:
47
+ while True:
48
+ try:
49
+ message = await session.recv()
50
+ except WebSocketDisconnect:
51
+ break
52
+ if message.get("type") != "command":
53
+ continue
54
+ command = message.get("text", "").strip()
55
+ if not command:
56
+ continue
57
+ response = process_command(char, command)
58
+ await session.send({
59
+ "type": "output",
60
+ "text": response,
61
+ "room": char.room.vnum if getattr(char, "room", None) else None,
62
+ "hp": char.hit,
63
+ })
64
+ while char.messages:
65
+ msg = char.messages.pop(0)
66
+ await session.send({
67
+ "type": "output",
68
+ "text": msg,
69
+ "room": char.room.vnum if getattr(char, "room", None) else None,
70
+ "hp": char.hit,
71
+ })
72
+ finally:
73
+ save_character(char)
74
+ if char.room:
75
+ char.room.remove_character(char)
76
+
77
+
78
+ def run(host: str = HOST, port: int = PORT) -> None:
79
+ uvicorn.run("mud.network.websocket_server:app", host=host, port=port)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ run()
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Any, Dict
4
+ from fastapi import WebSocket
5
+ from mud.models.character import Character
6
+
7
+
8
+ @dataclass
9
+ class WebSocketPlayerSession:
10
+ """Session wrapper for WebSocket clients."""
11
+
12
+ websocket: WebSocket
13
+ character: Character
14
+ name: str
15
+ session_type: str = "websocket"
16
+
17
+ async def send(self, payload: Dict[str, Any]) -> None:
18
+ await self.websocket.send_json(payload)
19
+
20
+ async def recv(self) -> Dict[str, Any]:
21
+ return await self.websocket.receive_json()
mud/notes.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Dict
5
+ import os
6
+
7
+ from mud.models.board import Board
8
+ from mud.models.board_json import BoardJson
9
+ from mud.models.json_io import load_dataclass, dump_dataclass
10
+
11
+ BOARDS_DIR = Path("data/boards")
12
+
13
+ board_registry: Dict[str, Board] = {}
14
+
15
+
16
+ def load_boards() -> None:
17
+ """Load all boards from ``BOARDS_DIR`` into ``board_registry``."""
18
+ board_registry.clear()
19
+ if not BOARDS_DIR.exists():
20
+ return
21
+ for path in BOARDS_DIR.glob("*.json"):
22
+ with path.open() as f:
23
+ data = load_dataclass(BoardJson, f)
24
+ board_registry[data.name] = Board.from_json(data)
25
+
26
+
27
+ def save_board(board: Board) -> None:
28
+ """Persist ``board`` to ``BOARDS_DIR`` atomically."""
29
+ BOARDS_DIR.mkdir(parents=True, exist_ok=True)
30
+ path = BOARDS_DIR / f"{board.name}.json"
31
+ tmp = path.with_suffix(".tmp")
32
+ with tmp.open("w") as f:
33
+ dump_dataclass(board.to_json(), f, indent=2)
34
+ f.flush()
35
+ os.fsync(f.fileno())
36
+ os.replace(tmp, path)
37
+
38
+
39
+ def get_board(name: str, description: str | None = None) -> Board:
40
+ """Fetch a board by name, creating it if necessary."""
41
+ board = board_registry.get(name)
42
+ if not board:
43
+ board = Board(name=name, description=description or name.title())
44
+ board_registry[name] = board
45
+ return board