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.
- endstone_clans_api/__init__.py +53 -0
- endstone_clans_api/api.py +29 -0
- endstone_clans_api/commands.py +430 -0
- endstone_clans_api/database.py +247 -0
- endstone_clans_api/etc.py +22 -0
- endstone_clans_api/events.py +136 -0
- endstone_clans_api/main.py +194 -0
- endstone_clans_api/py.typed +0 -0
- endstone_clans_api/types.py +85 -0
- endstone_clans_api-1.0.0.dist-info/METADATA +8 -0
- endstone_clans_api-1.0.0.dist-info/RECORD +13 -0
- endstone_clans_api-1.0.0.dist-info/WHEEL +5 -0
- endstone_clans_api-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -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,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,,
|