endstone-clans-api 1.0.0__py2.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.
@@ -0,0 +1,53 @@
1
+ from endstone.plugin import PluginManager
2
+ from .main import ClansApiPlugin
3
+ from .types import Clan
4
+ from .database import Database as ClansDatabase
5
+ from .api import ClansApi
6
+ from .events import (
7
+ ClanEvent,
8
+ ClanCancellableEvent,
9
+ ClanCreateEvent,
10
+ ClanDeleteEvent,
11
+ ClanJoinEvent,
12
+ ClanLeaveEvent,
13
+ ClanKickEvent,
14
+ ClanRenameEvent,
15
+ ClanInviteEvent,
16
+ clan_event_handler,
17
+ )
18
+ from typing import cast
19
+
20
+ def get_clans_api(plugin_manager: PluginManager) -> ClansApi | None:
21
+ """
22
+ Get the ClansApi object the plugin uses. Will return None if an error occours.
23
+
24
+ ## Note: Make sure you call this in on_enable or something similar. Internally, what this returns (ClansApiPlugin._api) is unset until the plugin's on_load() method gets called by the PluginManager
25
+ """
26
+ try:
27
+ plugin = cast(ClansApiPlugin, plugin_manager.get_plugin("ClansApiPlugin"))
28
+ # I don't trust how it returns "Plugin." For all I know, this could return
29
+ # Plugin | None, just like get_player.
30
+ if not plugin:
31
+ return None
32
+
33
+ return plugin.api
34
+ except Exception:
35
+ return None
36
+
37
+ __all__ = [
38
+ "ClansApiPlugin",
39
+ "Clan",
40
+ "ClansDatabase",
41
+ "ClansApi",
42
+ "get_clans_api",
43
+ "ClanEvent",
44
+ "ClanCancellableEvent",
45
+ "ClanCreateEvent",
46
+ "ClanDeleteEvent",
47
+ "ClanJoinEvent",
48
+ "ClanLeaveEvent",
49
+ "ClanKickEvent",
50
+ "ClanRenameEvent",
51
+ "ClanInviteEvent",
52
+ "clan_event_handler",
53
+ ]
@@ -0,0 +1,29 @@
1
+ from .types import Clan
2
+ from .database import Database
3
+ from .events import ClanEventManager, ClanEvent
4
+ from endstone.plugin import PluginManager
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from .main import ClansApiPlugin
9
+
10
+ class ClansApi:
11
+ # I _should_ go for a service, but it's confusing, and services do
12
+ # not provide type hints for the end user's langage server so it's
13
+ # better if I provide it as a function somewhere.
14
+
15
+ def __init__(self, clans_api_plugin: 'ClansApiPlugin'):
16
+ self.plugin = clans_api_plugin
17
+ self._event_manager = ClanEventManager(clans_api_plugin)
18
+
19
+ @property
20
+ def db(self) -> Database:
21
+ return self.plugin.db
22
+
23
+ def register_events(self, listener: Any) -> None:
24
+ """Registers clan event handlers."""
25
+ self._event_manager.register_events(listener)
26
+
27
+ def call_event(self, event: ClanEvent) -> None:
28
+ """Calls clan event handlers."""
29
+ self._event_manager.call_event(event)
@@ -0,0 +1,430 @@
1
+ from endstone import Logger
2
+ from endstone.scheduler import Scheduler
3
+ from endstone.form import MessageForm, ModalForm, Toggle
4
+ from .database import Database
5
+ from abc import ABC
6
+ from typing import TYPE_CHECKING, Callable, TypeVar
7
+ from endstone.command import CommandSender, Command
8
+ from endstone.asyncio import submit
9
+ from endstone import Player
10
+ from typing import Awaitable
11
+ import concurrent.futures
12
+ import time
13
+ import json
14
+ from .events import (
15
+ ClanCreateEvent,
16
+ ClanDeleteEvent,
17
+ ClanJoinEvent,
18
+ ClanLeaveEvent,
19
+ ClanKickEvent,
20
+ ClanRenameEvent,
21
+ ClanInviteEvent,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from .main import ClansConfig, ClansApiPlugin
26
+ from .api import ClansApi
27
+
28
+ _T = TypeVar("_T")
29
+
30
+ class Subcommands(ABC):
31
+ # def a_function(self, sender: CommandSender, command: Command, args: list[str]) -> bool: ...
32
+ # { "a": self.a_function }
33
+ # ^ this is what we're asking for (the dict)
34
+ # This should also accept unbound methods (methods that aren't object.method, but rather method(object)), but
35
+ # we won't be using unbound methods anyways so it doesn't matter
36
+ subcommand_map: dict[str, Callable[[CommandSender, Command, list[str]], bool]]
37
+ plugin: 'ClansApiPlugin'
38
+
39
+ @property
40
+ def config(self) -> 'ClansConfig':
41
+ return self.plugin.config
42
+
43
+ @property
44
+ def messages(self) -> dict[str, str]:
45
+ return self.plugin.config.messages
46
+
47
+ @property
48
+ def db(self) -> Database:
49
+ return self.plugin.db
50
+
51
+ @property
52
+ def scheduler(self) -> Scheduler:
53
+ return self.plugin.server.scheduler
54
+
55
+ @property
56
+ def logger(self) -> Logger:
57
+ return self.plugin.logger
58
+
59
+ def _handle_future_result(self, future) -> None:
60
+ try:
61
+ future.result()
62
+ except Exception as e:
63
+ self.logger.error(str(e))
64
+
65
+ def _submit_and_handle_future_result(self, coro: Awaitable[_T]) -> concurrent.futures.Future[_T]:
66
+ future = submit(coro)
67
+ future.add_done_callback(self._handle_future_result)
68
+ return future
69
+
70
+ class ClansCommands(Subcommands):
71
+ def help(self, sender: CommandSender, command: Command, args: list[str]):
72
+ help_messages = self.plugin.config.help
73
+
74
+ sender.send_message(self.messages.get("help_header", ""))
75
+ for subcommand in self.subcommand_map:
76
+ if subcommand in help_messages:
77
+ description = help_messages.get(subcommand)
78
+ else:
79
+ description = "[no description]"
80
+ sender.send_message(f"{subcommand} - {description}")
81
+
82
+ return True
83
+
84
+ def create(self, sender: CommandSender, command: Command, args: list[str]):
85
+ if not isinstance(sender, Player):
86
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
87
+ return True
88
+
89
+ if len(args) == 0:
90
+ sender.send_error_message(self.messages.get("usage_create", "Usage: /clan create <name: str>"))
91
+ return True
92
+
93
+ clan_name = " ".join(args)
94
+
95
+ async def create_task():
96
+ try:
97
+ xuid = int(sender.xuid)
98
+ member_clan = self.db.get_member_clan(xuid)
99
+ if member_clan:
100
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("already_in_clan", "already in clan")))
101
+ return
102
+
103
+ from .types import _Clan
104
+ temp_clan = _Clan(self.plugin, clan_name, xuid)
105
+ event = ClanCreateEvent(temp_clan, sender)
106
+ self.api.call_event(event)
107
+ if event.cancelled:
108
+ return
109
+
110
+ try:
111
+ self.db.create_clan(clan_name, xuid)
112
+ msg = self.messages.get("clan_created", "clan created")
113
+ msg = msg.replace("[clan_name]", clan_name)
114
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
115
+ except RuntimeError as e:
116
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(str(e)))
117
+ except Exception as e:
118
+ # What is the point
119
+ raise e
120
+
121
+ self._submit_and_handle_future_result(create_task())
122
+ return True
123
+
124
+ def rename(self, sender: CommandSender, command: Command, args: list[str]):
125
+ if not isinstance(sender, Player):
126
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
127
+ return True
128
+
129
+ if len(args) == 0:
130
+ sender.send_error_message(self.messages.get("usage_create", "Usage: /clan rename <name: str>"))
131
+ return True
132
+
133
+ player = sender
134
+
135
+ async def rename_task():
136
+ try:
137
+ xuid = int(player.xuid)
138
+ clan = self.db.get_member_clan(xuid)
139
+
140
+ if not clan:
141
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_in_clan", "not in clan")))
142
+ return
143
+
144
+ if clan.owner_xuid != xuid:
145
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_the_owner", "not the owner")))
146
+ return
147
+
148
+ try:
149
+ new_name = " ".join(args)
150
+
151
+ event = ClanRenameEvent(clan, clan.display_name, new_name)
152
+ self.api.call_event(event)
153
+ if event.cancelled:
154
+ return
155
+
156
+ self.db.rename_clan(xuid, new_name)
157
+ msg = self.messages.get("clan_renamed", "clan renamed to [clan_name]")
158
+ msg = msg.replace("[clan_name]", new_name)
159
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
160
+ except RuntimeError as e:
161
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(str(e)))
162
+
163
+ except Exception as e:
164
+ raise e
165
+
166
+ self._submit_and_handle_future_result(rename_task())
167
+ return True
168
+
169
+ def config_command(self, sender: CommandSender, command: Command, args: list[str]):
170
+ if not isinstance(sender, Player):
171
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
172
+ return True
173
+
174
+ player = sender
175
+
176
+ async def config_task():
177
+ xuid = int(player.xuid)
178
+ # Default to "true" if not set
179
+ allow_invites_str = self.db.get_player_preference(xuid, "allow_invites")
180
+ allow_invites = allow_invites_str.lower() == "true" if allow_invites_str else True
181
+
182
+ def on_form_submit(p: Player, data_json: str):
183
+ try:
184
+ data = json.loads(data_json)
185
+ new_allow_invites = data[0]
186
+
187
+ async def save_task():
188
+ self.db.set_player_preference(int(p.xuid), "allow_invites", str(new_allow_invites).lower())
189
+ self.scheduler.run_task(self.plugin, lambda: p.send_message(self.plugin.config.config_form.get("success", "Preferences updated!")))
190
+
191
+ submit(save_task())
192
+ except Exception as e:
193
+ self.logger.error(f"Error handling config form submission: {e}")
194
+
195
+ form = ModalForm(
196
+ title=self.plugin.config.config_form.get("title", "Clan Preferences"),
197
+ on_submit=on_form_submit
198
+ )
199
+ form.add_control(Toggle(
200
+ label=self.plugin.config.config_form.get("allow_invites", "Allow Clan Invitations"),
201
+ default_value=allow_invites
202
+ ))
203
+
204
+ self.scheduler.run_task(self.plugin, lambda: player.send_form(form))
205
+
206
+ submit(config_task())
207
+ return True
208
+
209
+ def invite(self, sender: CommandSender, command: Command, args: list[str]):
210
+ if not isinstance(sender, Player):
211
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
212
+ return True
213
+
214
+ if len(args) == 0:
215
+ sender.send_error_message(self.messages.get("usage_invite", "Usage: /clan invite <player: player>"))
216
+ return True
217
+
218
+ target_name = args[0]
219
+ target = self.plugin.server.get_player(target_name)
220
+ if not target:
221
+ msg = self.messages.get("player_not_found", "Player [player_name] not found.")
222
+ msg = msg.replace("[player_name]", target_name)
223
+ sender.send_error_message(msg)
224
+ return True
225
+
226
+ player = sender
227
+
228
+ async def invite_task():
229
+ try:
230
+ xuid = int(player.xuid)
231
+ clan = self.db.get_member_clan(xuid)
232
+
233
+ if not clan:
234
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_in_clan", "not in clan")))
235
+ return
236
+
237
+ if clan.owner_xuid != xuid:
238
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_the_owner", "not the owner")))
239
+ return
240
+
241
+ target_xuid = int(target.xuid)
242
+ target_clan = self.db.get_member_clan(target_xuid)
243
+ if target_clan:
244
+ msg = self.messages.get("player_already_in_clan", "[player_name] is already in a clan.")
245
+ msg = msg.replace("[player_name]", target.name)
246
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(msg))
247
+ return
248
+
249
+ allow_invites_str = self.db.get_player_preference(target_xuid, "allow_invites")
250
+ allow_invites = allow_invites_str.lower() == "true" if allow_invites_str else True
251
+ if not allow_invites:
252
+ msg = self.messages.get("privacy_no_invites", "[player_name] does not allow clan invitations.")
253
+ msg = msg.replace("[player_name]", target.name)
254
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(msg))
255
+ return
256
+
257
+ event = ClanInviteEvent(clan, player, target)
258
+ self.api.call_event(event)
259
+ if event.cancelled:
260
+ return
261
+
262
+ cooldown_key = (str(xuid), str(target_xuid))
263
+ now = time.time()
264
+ if cooldown_key in self.plugin.invite_cooldowns:
265
+ if now - self.plugin.invite_cooldowns[cooldown_key] < self.plugin.config.invite_cooldown:
266
+ msg = self.messages.get("invite_cooldown", "You must wait before inviting [player_name] again.")
267
+ msg = msg.replace("[player_name]", target.name)
268
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(msg))
269
+ return
270
+
271
+ def on_form_submit(p: Player, index: int):
272
+ if index == 0:
273
+ async def accept_task():
274
+ if self.db.get_member_clan(int(p.xuid)):
275
+ return
276
+
277
+ join_event = ClanJoinEvent(clan, p)
278
+ self.api.call_event(join_event)
279
+ if join_event.cancelled:
280
+ return
281
+
282
+ self.db.add_member(clan.owner_xuid, int(p.xuid))
283
+
284
+ def finalize_acceptance():
285
+ msg = self.messages.get("invite_accepted", "You have joined [clan_name]!")
286
+ msg = msg.replace("[clan_name]", clan.display_name)
287
+ p.send_message(msg)
288
+
289
+ inviter = self.plugin.server.get_player(player.name)
290
+ if inviter:
291
+ inviter.send_message(f"{p.name} joined your clan.")
292
+
293
+ self.scheduler.run_task(self.plugin, finalize_acceptance)
294
+
295
+ submit(accept_task())
296
+ else:
297
+ self.plugin.invite_cooldowns[cooldown_key] = time.time()
298
+
299
+ def notify_decline():
300
+ msg = self.messages.get("invite_declined", "[player_name] declined your invitation.")
301
+ msg = msg.replace("[player_name]", p.name)
302
+ inviter = self.plugin.server.get_player(player.name)
303
+ if inviter:
304
+ inviter.send_message(msg)
305
+
306
+ self.scheduler.run_task(self.plugin, notify_decline)
307
+
308
+ form = MessageForm(
309
+ title=self.messages.get("invite_received_title", "Clan Invitation"),
310
+ content=self.messages.get("invite_received_content", "[player_name] invited you to join [clan_name].")
311
+ .replace("[player_name]", player.name)
312
+ .replace("[clan_name]", clan.display_name),
313
+ button1=self.messages.get("invite_yes", "Yes"),
314
+ button2=self.messages.get("invite_no", "No"),
315
+ on_submit=on_form_submit
316
+ )
317
+
318
+ self.scheduler.run_task(self.plugin, lambda: target.send_form(form))
319
+
320
+ msg = self.messages.get("invite_sent", "Invitation sent to [player_name].")
321
+ msg = msg.replace("[player_name]", target.name)
322
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
323
+
324
+ except Exception as e:
325
+ raise e
326
+
327
+ self._submit_and_handle_future_result(invite_task())
328
+ return True
329
+
330
+ def kick(self, sender: CommandSender, command: Command, args: list[str]):
331
+ if not isinstance(sender, Player):
332
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
333
+ return True
334
+
335
+ if len(args) == 0:
336
+ sender.send_error_message(self.messages.get("usage_kick", "Usage: /clan kick <player: player>"))
337
+ return True
338
+
339
+ target_name = args[0]
340
+ target = self.plugin.server.get_player(target_name)
341
+ if not target:
342
+ msg = self.messages.get("player_not_found", "Player [player_name] not found.")
343
+ msg = msg.replace("[player_name]", target_name)
344
+ sender.send_error_message(msg)
345
+ return True
346
+
347
+ async def kick_task():
348
+ xuid = int(sender.xuid)
349
+ clan = self.db.get_clan_by_xuid(xuid)
350
+ target_xuid = int(target.xuid)
351
+
352
+ if not clan or target_xuid not in clan.members_xuids or xuid == target_xuid:
353
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("cannot_kick", "Cannot kick this player.")))
354
+ return
355
+
356
+ event = ClanKickEvent(clan, target, sender)
357
+ self.api.call_event(event)
358
+ if event.cancelled:
359
+ return
360
+
361
+ self.db.remove_member(xuid, target_xuid)
362
+
363
+ def notify():
364
+ msg = self.messages.get("player_kicked", "Kicked [player_name].").replace("[player_name]", target.name)
365
+ sender.send_message(msg)
366
+ target.send_message(self.messages.get("you_were_kicked", "You have been kicked from the clan."))
367
+
368
+ self.scheduler.run_task(self.plugin, notify)
369
+
370
+ self._submit_and_handle_future_result(kick_task())
371
+ return True
372
+
373
+ def leave(self, sender: CommandSender, command: Command, args: list[str]):
374
+ if not isinstance(sender, Player):
375
+ sender.send_error_message(self.messages.get("not_a_player", "Only players can use this command."))
376
+ return True
377
+
378
+ async def leave_task():
379
+ try:
380
+ xuid = int(sender.xuid)
381
+ clan = self.db.get_member_clan(xuid)
382
+
383
+ if not clan:
384
+ self.scheduler.run_task(self.plugin, lambda: sender.send_error_message(self.messages.get("not_in_clan", "not in clan")))
385
+ return
386
+
387
+ if clan.owner_xuid == xuid:
388
+ event = ClanDeleteEvent(clan)
389
+ self.api.call_event(event)
390
+ if event.cancelled:
391
+ return
392
+
393
+ self.db.delete_clan(xuid)
394
+ msg = self.messages.get("clan_disbanded", "clan disbanded")
395
+ msg = msg.replace("[clan_name]", clan.display_name)
396
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
397
+ else:
398
+ event = ClanLeaveEvent(clan, sender)
399
+ self.api.call_event(event)
400
+ if event.cancelled:
401
+ return
402
+
403
+ self.db.remove_member(clan.owner_xuid, xuid)
404
+ msg = self.messages.get("clan_left", "clan disbanded")
405
+ msg = msg.replace("[clan_name]", clan.display_name)
406
+ self.scheduler.run_task(self.plugin, lambda: sender.send_message(msg))
407
+ except Exception as e:
408
+ raise e
409
+
410
+ self._submit_and_handle_future_result(leave_task())
411
+ return True
412
+
413
+ def __init__(self, plugin: 'ClansApiPlugin'):
414
+ self.plugin = plugin
415
+
416
+ self.subcommand_map = {
417
+ "help": self.help,
418
+ "create": self.create,
419
+ "rename": self.rename,
420
+ "config": self.config_command,
421
+ "invite": self.invite,
422
+ "kick": self.kick,
423
+ "leave": self.leave,
424
+ }
425
+
426
+ @property
427
+ def api(self) -> 'ClansApi':
428
+ api = self.plugin.api
429
+ assert api
430
+ return api
@@ -0,0 +1,247 @@
1
+ import sqlite3
2
+ import threading
3
+ from pathlib import Path
4
+ from abc import ABC, abstractmethod
5
+ from .types import Clan
6
+ from .etc import remove_minecraft_formatting
7
+ from typing import Optional, TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from .main import ClansApiPlugin
11
+
12
+ class Database(ABC):
13
+ @abstractmethod
14
+ def close(self) -> None:
15
+ ...
16
+
17
+ @abstractmethod
18
+ def create_clan(self, name: str, owner_xuid: int) -> None:
19
+ ...
20
+
21
+ @abstractmethod
22
+ def get_clan(self, name: str) -> Optional[Clan]:
23
+ ...
24
+
25
+ @abstractmethod
26
+ def get_clan_by_xuid(self, xuid: int) -> Optional[Clan]:
27
+ ...
28
+
29
+ @abstractmethod
30
+ def get_members_xuids(self, owner_xuid: int) -> set[int]:
31
+ ...
32
+
33
+ @abstractmethod
34
+ def get_member_clan(self, member_xuid: int) -> Optional[Clan]:
35
+ # Only one clan per player should be allowed. Of course, that means
36
+ # they can only own one clan, or be in one clan.
37
+ ...
38
+
39
+ @abstractmethod
40
+ def delete_clan(self, owner_xuid: int) -> None:
41
+ ...
42
+
43
+ @abstractmethod
44
+ def rename_clan(self, owner_xuid: int, new_name: str) -> None:
45
+ ...
46
+
47
+ @abstractmethod
48
+ def add_member(self, owner_xuid: int, member_xuid: int) -> None:
49
+ ...
50
+
51
+ @abstractmethod
52
+ def remove_member(self, owner_xuid: int, member_xuid: int) -> None:
53
+ ...
54
+
55
+ @abstractmethod
56
+ def set_player_preference(self, xuid: int, key: str, value: str) -> None:
57
+ ...
58
+
59
+ @abstractmethod
60
+ def get_player_preference(self, xuid: int, key: str) -> Optional[str]:
61
+ ...
62
+
63
+ # The following was assisted by Claude
64
+ class _Database(Database):
65
+ def __init__(self, plugin: 'ClansApiPlugin', db_path: Path) -> None:
66
+ self.plugin = plugin
67
+ self.db_path = db_path
68
+ self._lock = threading.Lock()
69
+ self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
70
+ self._conn.execute("PRAGMA foreign_keys = ON")
71
+ self._init_db()
72
+
73
+ def close(self) -> None:
74
+ with self._lock:
75
+ self._conn.close()
76
+
77
+ def create_clan(self, name: str, owner_xuid: int) -> None:
78
+ with self._lock:
79
+ self._validate_name_availability(name)
80
+ self._create_clan(name, owner_xuid)
81
+
82
+ def get_clan(self, name: str) -> Optional[Clan]:
83
+ from .types import _Clan
84
+ clean_name = remove_minecraft_formatting(name).lower()
85
+ with self._lock:
86
+ row = self._get_clan(clean_name)
87
+ return _Clan._from_db(self.plugin, row) if row else None
88
+
89
+ def get_clan_by_xuid(self, xuid: int) -> Optional[Clan]:
90
+ from .types import _Clan
91
+ with self._lock:
92
+ row = self._get_clan_by_xuid(xuid)
93
+ return _Clan._from_db(self.plugin, row) if row else None
94
+
95
+ def get_members_xuids(self, owner_xuid: int) -> set[int]:
96
+ with self._lock:
97
+ return self._get_members_xuids(owner_xuid)
98
+
99
+ def get_member_clan(self, member_xuid: int) -> Optional[Clan]:
100
+ from .types import _Clan
101
+ with self._lock:
102
+ row = self._get_member_clan(member_xuid)
103
+ return _Clan._from_db(self.plugin, row) if row else None
104
+
105
+ def delete_clan(self, owner_xuid: int) -> None:
106
+ with self._lock:
107
+ self._delete_clan(owner_xuid)
108
+
109
+ def rename_clan(self, owner_xuid: int, new_name: str) -> None:
110
+ with self._lock:
111
+ self._validate_name_availability(new_name, exclude_owner_xuid=owner_xuid)
112
+ self._update_clan(owner_xuid, display_name=new_name)
113
+
114
+ def add_member(self, owner_xuid: int, member_xuid: int) -> None:
115
+ with self._lock:
116
+ self._add_member(owner_xuid, member_xuid)
117
+
118
+ def remove_member(self, owner_xuid: int, member_xuid: int) -> None:
119
+ with self._lock:
120
+ self._remove_member(owner_xuid, member_xuid)
121
+
122
+ def set_player_preference(self, xuid: int, key: str, value: str) -> None:
123
+ with self._lock:
124
+ self._conn.execute(
125
+ "INSERT OR REPLACE INTO player_preferences (xuid, key, value) VALUES (?, ?, ?)",
126
+ (xuid, key, value)
127
+ )
128
+ self._conn.commit()
129
+
130
+ def get_player_preference(self, xuid: int, key: str) -> Optional[str]:
131
+ with self._lock:
132
+ row = self._conn.execute(
133
+ "SELECT value FROM player_preferences WHERE xuid = ? AND key = ?",
134
+ (xuid, key)
135
+ ).fetchone()
136
+ return row[0] if row else None
137
+
138
+ def _validate_name_availability(self, name: str, exclude_owner_xuid: Optional[int] = None) -> None:
139
+ # Note: This is called within a lock already from public methods
140
+ from .types import _Clan
141
+ clean_name = remove_minecraft_formatting(name).lower()
142
+ row = self._get_clan(clean_name)
143
+ existing_clan = _Clan._from_db(self.plugin, row) if row else None
144
+
145
+ if existing_clan and existing_clan.owner_xuid != exclude_owner_xuid:
146
+ raise RuntimeError(f"Clan name '{name}' is already taken.")
147
+
148
+ def _init_db(self) -> None:
149
+ with self._lock:
150
+ # Cleanup old table if it exists
151
+ self._conn.execute("DROP TABLE IF EXISTS members")
152
+
153
+ self._conn.execute("""
154
+ CREATE TABLE IF NOT EXISTS clans (
155
+ owner_xuid INTEGER PRIMARY KEY,
156
+ clean_name TEXT UNIQUE NOT NULL,
157
+ display_name TEXT NOT NULL
158
+ )
159
+ """)
160
+ self._conn.execute("""
161
+ CREATE TABLE IF NOT EXISTS clan_members (
162
+ owner_xuid INTEGER NOT NULL,
163
+ member_xuid INTEGER NOT NULL UNIQUE,
164
+ PRIMARY KEY(owner_xuid, member_xuid),
165
+ FOREIGN KEY(owner_xuid) REFERENCES clans(owner_xuid) ON DELETE CASCADE
166
+ )
167
+ """)
168
+ self._conn.execute("""
169
+ CREATE TABLE IF NOT EXISTS player_preferences (
170
+ xuid INTEGER NOT NULL,
171
+ key TEXT NOT NULL,
172
+ value TEXT NOT NULL,
173
+ PRIMARY KEY(xuid, key)
174
+ )
175
+ """)
176
+ self._conn.commit()
177
+
178
+ def _create_clan(self, display_name: str, owner_xuid: int) -> None:
179
+ clean_name = remove_minecraft_formatting(display_name).lower()
180
+ self._conn.execute(
181
+ "INSERT INTO clans (owner_xuid, clean_name, display_name) VALUES (?, ?, ?)",
182
+ (owner_xuid, clean_name, display_name)
183
+ )
184
+ # Add owner as a member
185
+ self._conn.execute(
186
+ "INSERT INTO clan_members (owner_xuid, member_xuid) VALUES (?, ?)",
187
+ (owner_xuid, owner_xuid)
188
+ )
189
+ self._conn.commit()
190
+
191
+ def _get_clan(self, clean_name: str) -> Optional[tuple[int, str, str]]:
192
+ return self._conn.execute(
193
+ "SELECT owner_xuid, clean_name, display_name FROM clans WHERE clean_name = ?",
194
+ (clean_name,)
195
+ ).fetchone()
196
+
197
+ def _get_clan_by_xuid(self, owner_xuid: int) -> Optional[tuple[int, str, str]]:
198
+ return self._conn.execute(
199
+ "SELECT owner_xuid, clean_name, display_name FROM clans WHERE owner_xuid = ?",
200
+ (owner_xuid,)
201
+ ).fetchone()
202
+
203
+ def _get_members_xuids(self, owner_xuid: int) -> set[int]:
204
+ rows = self._conn.execute(
205
+ "SELECT member_xuid FROM clan_members WHERE owner_xuid = ?",
206
+ (owner_xuid,)
207
+ ).fetchall()
208
+ return {row[0] for row in rows}
209
+
210
+ def _get_member_clan(self, member_xuid: int) -> Optional[tuple[int, str, str]]:
211
+ return self._conn.execute("""
212
+ SELECT c.owner_xuid, c.clean_name, c.display_name
213
+ FROM clans c
214
+ JOIN clan_members m ON c.owner_xuid = m.owner_xuid
215
+ WHERE m.member_xuid = ?
216
+ """, (member_xuid,)).fetchone()
217
+
218
+ def _update_clan(
219
+ self,
220
+ owner_xuid: int,
221
+ display_name: str | None = None
222
+ ) -> None:
223
+ if display_name is not None:
224
+ clean_name = remove_minecraft_formatting(display_name).lower()
225
+ self._conn.execute(
226
+ "UPDATE clans SET clean_name = ?, display_name = ? WHERE owner_xuid = ?",
227
+ (clean_name, display_name, owner_xuid)
228
+ )
229
+ self._conn.commit()
230
+
231
+ def _delete_clan(self, owner_xuid: int) -> None:
232
+ self._conn.execute("DELETE FROM clans WHERE owner_xuid = ?", (owner_xuid,))
233
+ self._conn.commit()
234
+
235
+ def _add_member(self, owner_xuid: int, member_xuid: int) -> None:
236
+ self._conn.execute(
237
+ "INSERT OR IGNORE INTO clan_members (owner_xuid, member_xuid) VALUES (?, ?)",
238
+ (owner_xuid, member_xuid)
239
+ )
240
+ self._conn.commit()
241
+
242
+ def _remove_member(self, owner_xuid: int, member_xuid: int) -> None:
243
+ self._conn.execute(
244
+ "DELETE FROM clan_members WHERE owner_xuid = ? AND member_xuid = ?",
245
+ (owner_xuid, member_xuid)
246
+ )
247
+ self._conn.commit()
@@ -0,0 +1,22 @@
1
+ # Parts of this was Rust code I wrote translated to Python through an LLM.
2
+ # https://github.com/niko-at-chalupa/endstone-elytra-core/blob/main/crates/elytra-core/src/id.rs
3
+
4
+ from re import I
5
+ import re
6
+ import xxhash
7
+
8
+ # In Python, we convert the 8-byte string directly into an integer.
9
+ HASH_SEED = int.from_bytes(b"clansapi\0", byteorder="big")
10
+
11
+ def compute_id(kebab_name: str) -> int:
12
+ """
13
+ Generates a plugin ID that derives from the kebab-case name of plugins.
14
+ Returns an unsigned 64-bit integer.
15
+ """
16
+ # xxhash.xxh64 takes an integer seed and returns a 64-bit hash object
17
+ hasher = xxhash.xxh64(seed=HASH_SEED)
18
+ hasher.update(kebab_name.encode('utf-8'))
19
+ return hasher.intdigest()
20
+
21
+ def remove_minecraft_formatting(formatted_stuff: str) -> str:
22
+ return re.sub(r'§.', '', formatted_stuff)
@@ -0,0 +1,136 @@
1
+ from endstone import Player
2
+ from endstone.command import CommandSender
3
+ from .types import Clan
4
+ from abc import ABC
5
+ from typing import Callable, Type, Any, get_type_hints
6
+ import inspect
7
+ from endstone.event import EventPriority
8
+
9
+ class ClanEvent(ABC):
10
+ @property
11
+ def event_name(self) -> str:
12
+ return self.__class__.__name__
13
+
14
+ class ClanCancellableEvent(ClanEvent):
15
+ def __init__(self):
16
+ self._cancelled = False
17
+
18
+ @property
19
+ def cancelled(self) -> bool:
20
+ return self._cancelled
21
+
22
+ @cancelled.setter
23
+ def cancelled(self, value: bool):
24
+ self._cancelled = value
25
+
26
+ def cancel(self):
27
+ self._cancelled = True
28
+
29
+ def clan_event_handler(func=None, *, priority: EventPriority = EventPriority.NORMAL, ignore_cancelled: bool = False):
30
+ """
31
+ Decorator to register an event handler.
32
+
33
+ The first argument of the decorated method must be a subclass of ClanEvent.
34
+
35
+ # Example
36
+ ```python
37
+ @clan_event_handler
38
+ def on_some_event(event: SomeClanEvent):
39
+ ...
40
+ ```
41
+ """
42
+ def decorator(f):
43
+ setattr(f, "_is_clan_event_handler", True)
44
+ setattr(f, "_clan_priority", priority)
45
+ setattr(f, "_clan_ignore_cancelled", ignore_cancelled)
46
+ return f
47
+
48
+ if func:
49
+ return decorator(func)
50
+
51
+ return decorator
52
+
53
+ class ClanEventManager:
54
+ def __init__(self, plugin):
55
+ self._plugin = plugin
56
+ self._handlers: dict[Type[ClanEvent], list[Callable[[Any], Any]]] = {}
57
+
58
+ def register_events(self, listener: Any) -> None:
59
+ for attr_name in dir(listener):
60
+ attr = getattr(listener, attr_name)
61
+ if not callable(attr) or not getattr(attr, "_is_clan_event_handler", False):
62
+ continue
63
+
64
+ hints = get_type_hints(attr)
65
+ hints.pop("return", None)
66
+
67
+ event_type = next(iter(hints.values()), None)
68
+
69
+ if not inspect.isclass(event_type) or not issubclass(event_type, ClanEvent):
70
+ self._plugin.logger.error(f"Failed to register clan event handler {attr_name}: No ClanEvent type hint found.")
71
+ continue
72
+
73
+ if event_type not in self._handlers:
74
+ self._handlers[event_type] = []
75
+
76
+ self._handlers[event_type].append(attr)
77
+ self._handlers[event_type].sort(key=lambda x: getattr(x, "_clan_priority").value)
78
+
79
+ def call_event(self, event: ClanEvent) -> None:
80
+ for registered_type, handlers in self._handlers.items():
81
+ if isinstance(event, registered_type):
82
+ handler: Callable[[Any], Any]
83
+ for handler in handlers:
84
+ if isinstance(event, ClanCancellableEvent) and event.cancelled and not getattr(handler, "_clan_ignore_cancelled"):
85
+ continue
86
+ try:
87
+ handler(event)
88
+ except Exception as e:
89
+ handler_name = getattr(handler, "__name__", str(handler))
90
+ self._plugin.logger.error(f"Error while calling clan event handler {handler_name}: {e}")
91
+ import traceback
92
+ self._plugin.logger.error(traceback.format_exc())
93
+
94
+ class ClanCreateEvent(ClanCancellableEvent):
95
+ def __init__(self, clan: Clan, creator: Player):
96
+ super().__init__()
97
+ self.clan = clan
98
+ self.creator = creator
99
+
100
+ class ClanDeleteEvent(ClanCancellableEvent):
101
+ def __init__(self, clan: Clan):
102
+ super().__init__()
103
+ self.clan = clan
104
+
105
+ class ClanJoinEvent(ClanCancellableEvent):
106
+ def __init__(self, clan: Clan, player: Player):
107
+ super().__init__()
108
+ self.clan = clan
109
+ self.player = player
110
+
111
+ class ClanLeaveEvent(ClanCancellableEvent):
112
+ def __init__(self, clan: Clan, player: Player):
113
+ super().__init__()
114
+ self.clan = clan
115
+ self.player = player
116
+
117
+ class ClanKickEvent(ClanCancellableEvent):
118
+ def __init__(self, clan: Clan, player: Player, kicker: CommandSender):
119
+ super().__init__()
120
+ self.clan = clan
121
+ self.player = player
122
+ self.kicker = kicker
123
+
124
+ class ClanRenameEvent(ClanCancellableEvent):
125
+ def __init__(self, clan: Clan, old_name: str, new_name: str):
126
+ super().__init__()
127
+ self.clan = clan
128
+ self.old_name = old_name
129
+ self.new_name = new_name
130
+
131
+ class ClanInviteEvent(ClanCancellableEvent):
132
+ def __init__(self, clan: Clan, inviter: Player, invitee: Player):
133
+ super().__init__()
134
+ self.clan = clan
135
+ self.inviter = inviter
136
+ self.invitee = invitee
@@ -0,0 +1,194 @@
1
+ import traceback
2
+ from .commands import ClansCommands
3
+ from pathlib import Path
4
+ from typing import Any, cast
5
+ from endstone.plugin import Plugin
6
+ from pydantic import BaseModel, Field
7
+ from ruamel.yaml import YAML
8
+ from ruamel.yaml.comments import CommentedMap
9
+ from .database import Database, _Database
10
+ from endstone.command import Command, CommandSender
11
+ from .api import ClansApi
12
+
13
+ class ClansConfig(BaseModel):
14
+ messages: dict[str, str] = Field(default_factory=dict)
15
+ help: dict[str, str] = Field(default_factory=dict)
16
+ config_form: dict[str, str] = Field(default_factory=dict)
17
+ invite_cooldown: int = 600
18
+
19
+ class ClansApiPlugin(Plugin):
20
+ api_version = "0.11"
21
+ _config: ClansConfig
22
+ _api: ClansApi | None = None
23
+ _invite_cooldowns: dict[tuple[str, str], float] = {} # (inviter_xuid, target_xuid) -> timestamp
24
+
25
+ commands = {
26
+ "clan": {
27
+ "description": "Manage or create a clan",
28
+ "usages": [
29
+ "/clan <subcommand: string> [args: message]",
30
+ "/clan help",
31
+ "/clan create <name: str>",
32
+ "/clan rename <name: str>",
33
+ "/clan config",
34
+ "/clan invite <player: player>",
35
+ "/clan kick <player: player>",
36
+ "/clan leave",
37
+ ],
38
+ "permissions": ["clans-api.command"],
39
+ }
40
+ }
41
+
42
+ permissions = {
43
+ "clans-api.command": {
44
+ "description": "Base permission for all endstone clans API commands",
45
+ "default": True,
46
+ }
47
+ }
48
+
49
+ def on_load(self):
50
+ self._api = ClansApi(self)
51
+
52
+ def on_enable(self):
53
+ self.data_folder.mkdir(exist_ok=True)
54
+ self._config = self._load_config()
55
+ self._db = _Database(self, self.data_folder / "clans.db")
56
+ self._invite_cooldowns = {}
57
+ self.register_events(self)
58
+ self.clans_commands = ClansCommands(self)
59
+
60
+ def on_disable(self):
61
+ if hasattr(self, "_db"):
62
+ self._db.close()
63
+
64
+ @property
65
+ def invite_cooldowns(self) -> dict[tuple[str, str], float]:
66
+ return self._invite_cooldowns
67
+
68
+ def on_command(self, sender: CommandSender, command: Command, args: list[str]) -> bool:
69
+ if command.name != "clan":
70
+ return False
71
+
72
+ if len(args) == 0:
73
+ sender.send_error_message(self.config.messages.get("no_subcommand", "no subcommand"))
74
+ return False
75
+
76
+ subcommand = self.clans_commands.subcommand_map.get(args[0])
77
+ if not subcommand:
78
+ sender.send_error_message(self.config.messages.get("invalid_subcommand", "invalid subcommand"))
79
+ return False
80
+
81
+ try:
82
+ # args[1] is actually just a string of the rest of the args
83
+ # since we take in a "message" type. We have to manually split.
84
+ return subcommand(sender, command, args[1].split() if len(args) > 1 else [])
85
+ except Exception as e:
86
+ self.logger.error(f"ERROR !!!!!!!!!!!!! 😭😭😭 While handling subcommand `{args[0]}` for `{sender.name}`!! 🥺🥺🥺")
87
+ self.logger.error(str(e))
88
+ self.logger.error(traceback.format_exc())
89
+
90
+ sender.send_error_message(self.config.messages.get("generic_error", "generic error"))
91
+
92
+ return False
93
+
94
+ @property
95
+ def config(self) -> ClansConfig:
96
+ return self._config
97
+
98
+ @property
99
+ def api(self) -> ClansApi | None:
100
+ return self._api
101
+
102
+ @property
103
+ def db(self) -> Database:
104
+ return self._db
105
+
106
+ def _load_config(self) -> ClansConfig:
107
+ folder = Path(self.data_folder)
108
+ folder.mkdir(parents=True, exist_ok=True)
109
+ cfg_path = folder / "config.yml"
110
+
111
+ yml = YAML()
112
+ yml.version = (1, 2)
113
+ yml.preserve_quotes = False
114
+
115
+ defaults = [
116
+ ("invite_cooldown", 600, "The duration of invitation cooldown in seconds"),
117
+ ("messages.no_permission", "You do not have permission to use this command.", "Message shown when a player lacks permission"),
118
+ ("messages.clan_created", "Clan [clan_name] has been successfully created!", "Message shown when a clan is created"),
119
+ ("messages.no_subcommand", "No subcommand was provided. Try /clans help.", "Shown when /clans is used with no arguments"),
120
+ ("messages.invalid_subcommand", "The subcommand provided isn't valid. Try /clan help.", "Shown when /clan is used with an invalid subcommand"),
121
+ ("messages.generic_error", "A technical error has occoured. Please contact a server admin or owner.", "Generic error for commands"),
122
+ ("messages.not_a_player", "Only players can use this command.", "Message shown when a non-player uses a player-only command"),
123
+ ("messages.already_in_clan", "You're already in a clan!", "Message shown when a player tries to join/create a clan while in one"),
124
+ ("messages.clan_name_taken", "A clan with that name already exists!", "Message shown when a clan name is already in use"),
125
+ ("messages.usage_create", "Usage: /clan create <name>", "Message shown when /clan create is used incorrectly"),
126
+ ("messages.not_in_clan", "You're not in a clan!", "Message shown when a player tries to use a clan command but isn't in one"),
127
+ ("messages.clan_left", "You have left the clan [clan_name].", "Message shown when a player leaves a clan"),
128
+ ("messages.clan_disbanded", "Your clan [clan_name] has been disbanded.", "Message shown when a clan is disbanded because the owner left"),
129
+ ("messages.help_header", "--- Clan Help ---", "Goes atop the help area."),
130
+ ("messages.not_in_a_clan", "You are NOT in a clan!!", "Message shown when player attempts to do a clan-related action when NOT in a clan."),
131
+ ("messages.not_the_owner", "You're NOT the owner of the clan!!", "Message shown when player attempts to do a clan-related action when NOT the owner."),
132
+ ("messages.clan_name_already_taken", "That name is already taken!", "Message shown to players if a clan name is already taken."),
133
+ ("messages.player_not_found", "Player [player_name] not found.", "Message shown when a player is not online"),
134
+ ("messages.invite_sent", "Invitation sent to [player_name].", "Message shown when an invitation is sent"),
135
+ ("messages.invite_received_title", "Clan Invitation", "Title of the invitation form"),
136
+ ("messages.invite_received_content", "[player_name] invited you to join [clan_name].", "Content of the invitation form"),
137
+ ("messages.invite_yes", "Yes", "Accept button text"),
138
+ ("messages.invite_no", "No", "Decline button text"),
139
+ ("messages.invite_cooldown", "You must wait before inviting [player_name] again.", "Message shown when an invitation is on cooldown"),
140
+ ("messages.invite_accepted", "You have joined [clan_name]!", "Message shown when a player accepts an invitation"),
141
+ ("messages.invite_declined", "[player_name] declined your invitation.", "Message shown when a player declines an invitation"),
142
+ ("messages.player_already_in_clan", "[player_name] is already in a clan.", "Message shown when inviting someone already in a clan"),
143
+ ("messages.privacy_no_invites", "[player_name] does not allow clan invitations.", "Message shown when inviting someone who disabled invites"),
144
+ ("messages.usage_kick", "Usage: /clan kick <player>", "Message shown when /clan kick is used incorrectly"),
145
+ ("messages.cannot_kick", "You cannot kick this player (are you the owner? are they in your clan?)", "Shown when a kick fails"),
146
+ ("messages.player_kicked", "[player_name] has been kicked from the clan.", "Success message for kicking"),
147
+ ("messages.you_were_kicked", "You have been kicked from the clan.", "Message shown to the player who was kicked"),
148
+
149
+ ("config_form.title", "Clan Preferences", "Title of the player preferences form"),
150
+ ("config_form.allow_invites", "Allow Clan Invitations", "Toggle label for invitations"),
151
+ ("config_form.success", "Your preferences have been updated!", "Message shown when preferences are saved"),
152
+
153
+ # Everything underneath the help.* namespace is the help description for the
154
+ # command specified.
155
+ ("help.help", "Show this help message", "/clan help"),
156
+ ("help.rename", "Rename a clan. Example: /clan rename \"my old clan\" \"my new one\"", "/clan rename"),
157
+ ("help.config", "Open the clan preferences menu", "/clan config"),
158
+ ("help.invite", "Invite a player to your clan", "/clan invite"),
159
+ ("help.kick", "Remote a player from your clan", "/clan kick"),
160
+ ("help.leave", "Makes you leave the clan you're in. If you're the owner of the clan you leave, then the clan will be deleted.", "/clan leave")
161
+ ]
162
+
163
+ if cfg_path.exists():
164
+ with open(cfg_path, "r", encoding="utf-8") as f:
165
+ existing = yml.load(f)
166
+ if not isinstance(existing, CommentedMap):
167
+ existing = CommentedMap(existing or {})
168
+ else:
169
+ existing = CommentedMap()
170
+
171
+ for key, default, comment in defaults:
172
+ keys = key.split(".")
173
+ current = existing
174
+ for i, k in enumerate(keys[:-1]):
175
+ if k not in current:
176
+ current[k] = CommentedMap()
177
+ current = current[k]
178
+
179
+ if keys[-1] not in current:
180
+ current[keys[-1]] = default
181
+ current.yaml_add_eol_comment(comment, keys[-1])
182
+
183
+ with open(cfg_path, "w", encoding="utf-8") as f:
184
+ yml.dump(existing, f)
185
+
186
+ config_dict = self._commented_map_to_dict(existing)
187
+ return ClansConfig(**config_dict)
188
+
189
+ def _commented_map_to_dict(self, data: Any) -> Any:
190
+ if isinstance(data, CommentedMap):
191
+ return {k: self._commented_map_to_dict(v) for k, v in data.items()}
192
+ elif isinstance(data, list):
193
+ return [self._commented_map_to_dict(v) for v in data]
194
+ return data
File without changes
@@ -0,0 +1,85 @@
1
+ from .etc import compute_id, remove_minecraft_formatting
2
+ from abc import ABC, abstractmethod
3
+ from typing import TYPE_CHECKING
4
+ from endstone import asyncio
5
+
6
+ if TYPE_CHECKING:
7
+ from .main import ClansApiPlugin
8
+
9
+ class Clan(ABC):
10
+ @property
11
+ @abstractmethod
12
+ def display_name(self) -> str:
13
+ """
14
+ The formatted display name of the clan.
15
+
16
+ Use this in frontened, no where else.
17
+ """
18
+ ...
19
+
20
+ @property
21
+ @abstractmethod
22
+ def owner_xuid(self) -> int:
23
+ """
24
+ The XUID of the clan owner.
25
+
26
+ Primary key in the database--immutable (owner transfers? forget that!!)
27
+ """
28
+ ...
29
+
30
+ @property
31
+ @abstractmethod
32
+ def clean_name(self) -> str:
33
+ """
34
+ The sanitized, **unique**, and lowered name without Minecraft formatting.
35
+
36
+ Use internally and as a substitute name in commands.
37
+ """
38
+ ...
39
+
40
+ @property
41
+ @abstractmethod
42
+ def members_xuids(self) -> set[int]:
43
+ """
44
+ XUIDs of every player in this clan, including the owner.
45
+ """
46
+
47
+ class _Clan(Clan):
48
+ _members_xuids: set[int]
49
+ _display_name: str
50
+ # Primary key
51
+ _owner_xuid: int
52
+ # Internal name, set as UNIQUE, remove Minecraft formatting, use as a
53
+ # substitute name in commands.
54
+ _clean_name: str
55
+
56
+ def __init__(self, plugin: 'ClansApiPlugin', display_name: str, owner_xuid: int):
57
+ self._display_name = display_name
58
+ self._owner_xuid = owner_xuid
59
+ self._plugin = plugin
60
+
61
+ self._members_xuids = set()
62
+ self._clean_name = remove_minecraft_formatting(display_name).lower()
63
+
64
+ @property
65
+ def display_name(self) -> str:
66
+ return self._display_name
67
+
68
+ @property
69
+ def owner_xuid(self) -> int:
70
+ return self._owner_xuid
71
+
72
+ @property
73
+ def clean_name(self) -> str:
74
+ return self._clean_name
75
+
76
+ @property
77
+ def members_xuids(self) -> set[int]:
78
+ return self._members_xuids
79
+
80
+ @classmethod
81
+ def _from_db(cls, plugin: 'ClansApiPlugin', row: tuple) -> '_Clan':
82
+ owner_xuid, clean_name, display_name = row
83
+ clan = cls(plugin, display_name, owner_xuid)
84
+ clan._members_xuids = plugin.db.get_members_xuids(owner_xuid)
85
+ return clan
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: endstone-clans-api
3
+ Version: 1.0.0
4
+ Author: niko-at-chalupa
5
+ Requires-Dist: endstone
6
+ Requires-Dist: pydantic
7
+ Requires-Dist: ruamel-yaml
8
+ Requires-Dist: xxhash
@@ -0,0 +1,13 @@
1
+ endstone_clans_api/__init__.py,sha256=M3JT6mn6u1bCiy9jR290OStafI65Q3vmvsn5zdHM8O0,1475
2
+ endstone_clans_api/api.py,sha256=gHWLChz0_EdLw_Ing4dOrP0qPZ1XmAOyGVPLKfs4MLE,986
3
+ endstone_clans_api/commands.py,sha256=_jAloWnmofy6TngiAYlE_WPXbBgrKiyFNp0j35rnfXI,18241
4
+ endstone_clans_api/database.py,sha256=avgwvSl41v0yLNt5hEp82STEDtgyBzkmwoZIR1Le_90,8938
5
+ endstone_clans_api/etc.py,sha256=onnbv2V_GFfKrZQaap_PAWLasBCGg1jKjNIhFP0qwH8,816
6
+ endstone_clans_api/events.py,sha256=nyNtysWXuP1UyHFuhglHbNbhXQsG4y2QeQiXjg60JAk,4576
7
+ endstone_clans_api/main.py,sha256=EK-J74zEL3QAtx_c7Q3_sFwsAljoyNn6jIyb057GMXY,10216
8
+ endstone_clans_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ endstone_clans_api/types.py,sha256=GAcs10aJF8TDAMNFmxF1PvsSDxpcNkwzWpdOnD1USXk,2257
10
+ endstone_clans_api-1.0.0.dist-info/METADATA,sha256=Klx3zSKR34cRskL-wqIRRwo5S4-GLATyPbTdpZRCBCc,183
11
+ endstone_clans_api-1.0.0.dist-info/WHEEL,sha256=VX-VJ7c6dw9Ge3EqJIbA6W3pOUbz24SnnGGFNr55jY4,105
12
+ endstone_clans_api-1.0.0.dist-info/entry_points.txt,sha256=5lle5993ULf8qyfNgAiYMugMPdMxklFMhfwjktdNC_A,57
13
+ endstone_clans_api-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [endstone]
2
+ clans-api = endstone_clans_api:ClansApiPlugin