Skip to content

Commit 92a13fb

Browse files
committed
feat: Implement per-server configuration for all XP and leveling system settings.
1 parent c2af83a commit 92a13fb

File tree

7 files changed

+147
-116
lines changed

7 files changed

+147
-116
lines changed

config/config.json.example

Lines changed: 8 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,6 @@
5252
"123456789012345678": [
5353
987654321098765432,
5454
876543210987654321
55-
],
56-
"234567890123456789": [
57-
765432109876543210
5855
]
5956
},
6057
"XP_ROLES": {
@@ -67,55 +64,31 @@
6764
"level": 10,
6865
"role_id": 876543210987654321
6966
}
70-
],
71-
"234567890123456789": [
72-
{
73-
"level": 3,
74-
"role_id": 765432109876543210
75-
},
76-
{
77-
"level": 7,
78-
"role_id": 654321098765432109
79-
}
8067
]
8168
},
8269
"XP_MULTIPLIERS": {
8370
"123456789012345678": [
8471
{
85-
"role_id": 987654321098765432,
86-
"multiplier": 1.5
72+
"multiplier": 1.5,
73+
"role_id": 987654321098765432
8774
},
8875
{
89-
"role_id": 876543210987654321,
90-
"multiplier": 2.0
91-
}
92-
],
93-
"234567890123456789": [
94-
{
95-
"role_id": 765432109876543210,
96-
"multiplier": 1.25
76+
"multiplier": 2.0,
77+
"role_id": 876543210987654321
9778
}
9879
]
9980
},
10081
"XP_COOLDOWN": {
101-
"0": 1,
102-
"123456789012345678": 5,
103-
"234567890123456789": 3
82+
"0": 1
10483
},
10584
"LEVELS_EXPONENT": {
106-
"0": 2.0,
107-
"123456789012345678": 1.5,
108-
"234567890123456789": 2.5
85+
"0": 2.0
10986
},
11087
"SHOW_XP_PROGRESS": {
111-
"0": true,
112-
"123456789012345678": true,
113-
"234567890123456789": false
88+
"0": true
11489
},
11590
"ENABLE_XP_CAP": {
116-
"0": false,
117-
"123456789012345678": false,
118-
"234567890123456789": true
91+
"0": false
11992
}
12093
},
12194
"SNIPPETS": {

docs/content/reference/env.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,13 @@ XP system configuration.
135135

136136
| Name | Type | Default | Description | Example |
137137
|------|------|---------|-------------|---------|
138-
| `XP_CONFIG__XP_BLACKLIST_CHANNELS` | `array` | `[]` | XP blacklist channels | `[123456789012345678]` |
139-
| `XP_CONFIG__XP_ROLES` | `array` | `[]` | XP roles | `[{"level": 5, "role_id": 123456789012345678}]` |
140-
| `XP_CONFIG__XP_MULTIPLIERS` | `array` | `[]` | XP multipliers | `[{"multiplier": 1.5, "role_id": 123456789012345678}]` |
141-
| `XP_CONFIG__XP_COOLDOWN` | `integer` | `1` | XP cooldown in seconds | `1`, `5`, `10` |
142-
| `XP_CONFIG__LEVELS_EXPONENT` | `number` | `2.0` | Levels exponent | `2`, `3`, `1.5` |
143-
| `XP_CONFIG__SHOW_XP_PROGRESS` | `boolean` | `true` | Show XP progress | `true`, `false` |
144-
| `XP_CONFIG__ENABLE_XP_CAP` | `boolean` | `false` | Enable XP cap | `false`, `true` |
138+
| `XP_CONFIG__XP_BLACKLIST_CHANNELS` | `object` | `{}` | XP blacklist channels per server | `{"123456789012345678": [987654321098765432, 876543210987654321]}` |
139+
| `XP_CONFIG__XP_ROLES` | `object` | `{}` | Per server XP roles | `{"123456789012345678": [{"level": 5, "role_id": 987654321098765432}, {"level": 10, "role_id": 876543210987654321}]}` |
140+
| `XP_CONFIG__XP_MULTIPLIERS` | `object` | `{}` | XP multipliers per server | `{"123456789012345678": [{"multiplier": 1.5, "role_id": 987654321098765432}, {"multiplier": 2.0, "role_id": 876543210987654321}]}` |
141+
| `XP_CONFIG__XP_COOLDOWN` | `object` | `{"0": 1}` | XP cooldown in seconds per server (0 for default) | `{"0": 1, "123456789012345678": 5, "987654321098765432": 10}` |
142+
| `XP_CONFIG__LEVELS_EXPONENT` | `object` | `{"0": 2.0}` | Levels exponent per server (0 for default) | `{"0": 2.0, "123456789012345678": 1.5, "987654321098765432": 3.0}` |
143+
| `XP_CONFIG__SHOW_XP_PROGRESS` | `object` | `{"0": true}` | Show XP progress per server (0 for default) | `{"0": true, "123456789012345678": false}` |
144+
| `XP_CONFIG__ENABLE_XP_CAP` | `object` | `{"0": false}` | Enable XP cap per server (0 for default) | `{"0": false, "123456789012345678": true}` |
145145

146146
### Snippets
147147

scripts/config/generate.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,10 @@ def generate_schema() -> None:
150150
json.dump(schema, f, indent=2, ensure_ascii=False)
151151

152152

153-
def _is_env_key(ek: str) -> bool:
153+
def _is_env_key(ek: Any) -> bool:
154154
"""Return True if this key belongs in .env (Postgres, Valkey, ExternalServices, BOT_TOKEN, DATABASE_URL, DEBUG, LOG_LEVEL, MAINTENANCE_MODE)."""
155+
if not isinstance(ek, str):
156+
return False
155157
e = ek.upper()
156158
if e in {
157159
"BOT_TOKEN",
@@ -202,12 +204,12 @@ def _flatten_for_env(prefix: str, obj: Any) -> dict[str, str]:
202204
out: dict[str, str] = {}
203205
if isinstance(obj, dict):
204206
for k, v in obj.items():
205-
env_key = (prefix + k).upper()
207+
env_key = (prefix + str(k)).upper()
206208
if isinstance(v, dict):
207209
if not v:
208210
out[env_key] = "{}"
209211
elif any(isinstance(x, (dict, list)) for x in v.values()):
210-
out.update(_flatten_for_env(prefix + k + "__", v))
212+
out.update(_flatten_for_env(prefix + str(k) + "__", v))
211213
else:
212214
out[env_key] = json.dumps(v)
213215
elif isinstance(v, list):

src/tux/modules/features/levels.py

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -42,25 +42,27 @@ def __init__(self, bot: Tux) -> None:
4242

4343
self.xp_cooldown = CONFIG.XP_CONFIG.XP_COOLDOWN
4444
self.levels_exponent = CONFIG.XP_CONFIG.LEVELS_EXPONENT
45-
45+
4646
# The xp_roles and max_level are now handled per server
4747
self.enable_xp_cap = CONFIG.XP_CONFIG.ENABLE_XP_CAP
48-
48+
4949
def get_max_level(self, guild_id: int) -> int:
5050
"""
5151
Get the maximum configured level for a guild.
52-
52+
5353
Parameters
5454
----------
5555
guild_id : int
5656
The guild ID
57-
57+
5858
Returns
5959
-------
6060
int
6161
The maximum level configured for the guild, or 0 if no levels are configured
6262
"""
63-
if guild_id in CONFIG.XP_CONFIG.XP_ROLES and (guild_roles := CONFIG.XP_CONFIG.XP_ROLES[guild_id]):
63+
if guild_id in CONFIG.XP_CONFIG.XP_ROLES and (
64+
guild_roles := CONFIG.XP_CONFIG.XP_ROLES[guild_id]
65+
):
6466
return max(item["level"] for item in guild_roles)
6567
return 0
6668

@@ -75,15 +77,15 @@ async def xp_listener(self, message: discord.Message) -> None:
7577
The message object.
7678
"""
7779
try:
78-
# Skip XP processing during maintenance mode
79-
if getattr(self.bot, "maintenance_mode", False):
80+
# Skip XP processing for bots, DMs, maintenance mode, or blacklisted channels
81+
is_maintenance = getattr(self.bot, "maintenance_mode", False)
82+
if message.author.bot or not message.guild or is_maintenance:
8083
return
8184

82-
if message.author.bot or not message.guild:
83-
return
84-
85-
# Check if this channel is in the blacklist for this guild
86-
guild_blacklist = CONFIG.XP_CONFIG.XP_BLACKLIST_CHANNELS.get(message.guild.id, [])
85+
guild_blacklist = CONFIG.XP_CONFIG.XP_BLACKLIST_CHANNELS.get(
86+
message.guild.id,
87+
[],
88+
)
8789
if message.channel.id in guild_blacklist:
8890
return
8991

@@ -111,7 +113,10 @@ async def xp_listener(self, message: discord.Message) -> None:
111113
last_message_time = (
112114
user_level_data.last_message if user_level_data else None
113115
)
114-
if last_message_time and self.is_on_cooldown(last_message_time, message.guild.id):
116+
if last_message_time and self.is_on_cooldown(
117+
last_message_time,
118+
message.guild.id,
119+
):
115120
return
116121

117122
# Process XP gain with the already fetched data
@@ -141,8 +146,11 @@ async def process_xp_gain(
141146
current_level = user_level_data.level if user_level_data else 0
142147

143148
# Get the server-specific exponent or use default (0 is default key)
144-
server_exponent = self.levels_exponent.get(guild.id, self.levels_exponent.get(0, 2.0))
145-
149+
server_exponent = self.levels_exponent.get(
150+
guild.id,
151+
self.levels_exponent.get(0, 2.0),
152+
)
153+
146154
# Recalculate what level the current XP should be (in case exponent changed)
147155
# This ensures level is always correct based on current XP and exponent
148156
expected_level_from_xp = self.calculate_level(current_xp, guild.id)
@@ -159,10 +167,13 @@ async def process_xp_gain(
159167
xp_increment = self.calculate_xp_increment(member, guild.id)
160168
new_xp = current_xp + xp_increment
161169
new_level = self.calculate_level(new_xp, guild.id)
162-
170+
163171
# Log if there's a suspicious level jump (more than 5 levels from expected)
164172
if new_level > expected_level_from_xp + 5:
165-
server_exponent = self.levels_exponent.get(guild.id, self.levels_exponent.get(0, 2.0))
173+
server_exponent = self.levels_exponent.get(
174+
guild.id,
175+
self.levels_exponent.get(0, 2.0),
176+
)
166177
logger.warning(
167178
f"Suspicious level jump detected for {member.name} ({member.id}): "
168179
f"Level {expected_level_from_xp} -> {new_level} (XP: {current_xp:.2f} -> {new_xp:.2f}, "
@@ -190,7 +201,11 @@ async def process_xp_gain(
190201
)
191202
await self.handle_level_up(member, guild, new_level)
192203

193-
def is_on_cooldown(self, last_message_time: datetime.datetime, guild_id: int = 0) -> bool:
204+
def is_on_cooldown(
205+
self,
206+
last_message_time: datetime.datetime,
207+
guild_id: int = 0,
208+
) -> bool:
194209
"""
195210
Check if the member is on cooldown.
196211
@@ -284,19 +299,26 @@ async def update_roles(
284299

285300
role_ids = {role["role_id"] for role in guild_roles}
286301
roles_to_remove = [
287-
r for r in guild_member.roles
288-
if r.id in role_ids and r != highest_role
302+
r for r in guild_member.roles if r.id in role_ids and r != highest_role
289303
]
290304

291305
if roles_to_remove:
292306
await guild_member.remove_roles(*roles_to_remove)
293307

294308
if highest_role or roles_to_remove:
295-
assigned_text = f"Assigned {highest_role.name}" if highest_role else "No role assigned"
296-
removed_text = f", Removed: {', '.join(r.name for r in roles_to_remove)}" if roles_to_remove else ""
309+
assigned_text = (
310+
f"Assigned {highest_role.name}"
311+
if highest_role
312+
else "No role assigned"
313+
)
314+
removed_text = (
315+
f", Removed: {', '.join(r.name for r in roles_to_remove)}"
316+
if roles_to_remove
317+
else ""
318+
)
297319
logger.debug(
298320
f"Updated roles for {guild_member} in {g}: "
299-
f"{assigned_text}{removed_text}"
321+
f"{assigned_text}{removed_text}",
300322
)
301323

302324
@staticmethod
@@ -336,7 +358,11 @@ def calculate_xp_for_level(self, level: int, guild_id: int = 0) -> float:
336358
exponent = self.levels_exponent.get(guild_id, self.levels_exponent.get(0, 2.0))
337359
return 500 * (level / 5) ** exponent
338360

339-
def calculate_xp_increment(self, member: discord.Member, guild_id: int = 0) -> float:
361+
def calculate_xp_increment(
362+
self,
363+
member: discord.Member,
364+
guild_id: int = 0,
365+
) -> float:
340366
"""
341367
Calculate the XP increment for a member.
342368
@@ -359,7 +385,7 @@ def calculate_xp_increment(self, member: discord.Member, guild_id: int = 0) -> f
359385
role["role_id"]: role["multiplier"]
360386
for role in CONFIG.XP_CONFIG.XP_MULTIPLIERS[guild_id]
361387
}
362-
388+
363389
return max(
364390
(guild_multipliers.get(role.id, 1) for role in member.roles),
365391
default=1,
@@ -383,15 +409,17 @@ def calculate_level(self, xp: float, guild_id: int = 0) -> int:
383409
"""
384410
# Ensure XP is non-negative to prevent complex number errors
385411
xp = max(0.0, xp)
386-
412+
387413
# Get server-specific exponent or use default
388414
exponent = self.levels_exponent.get(guild_id, self.levels_exponent.get(0, 2.0))
389-
415+
390416
# Guard against division by zero
391417
if exponent == 0:
392-
logger.error(f"levels_exponent for guild {guild_id} cannot be 0, using default value of 2")
418+
logger.error(
419+
f"levels_exponent for guild {guild_id} cannot be 0, using default value of 2",
420+
)
393421
exponent = 2.0
394-
422+
395423
return int((xp / 500) ** (1 / exponent) * 5)
396424

397425
# *NOTE* Do not move this function to utils.py, as this results in a circular import.
@@ -458,7 +486,12 @@ def generate_progress_bar(
458486

459487
return f"`{bar}` {current_value}/{target_value}"
460488

461-
def get_level_progress(self, xp: float, level: int, guild_id: int = 0) -> tuple[int, int]:
489+
def get_level_progress(
490+
self,
491+
xp: float,
492+
level: int,
493+
guild_id: int = 0,
494+
) -> tuple[int, int]:
462495
"""
463496
Get the progress towards the next level.
464497

src/tux/modules/levels/level.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,18 +82,27 @@ async def level(
8282

8383
logger.debug(f"Level check for {member.name} ({member.id}) in {ctx.guild.name}")
8484

85-
xp: float = await self.db.levels.get_xp(member.id, 0)
86-
level: int = await self.db.levels.get_level(member.id, 0)
85+
guild_id = ctx.guild.id
86+
xp: float = await self.db.levels.get_xp(member.id, guild_id)
87+
level: int = await self.db.levels.get_level(member.id, guild_id)
8788

8889
logger.debug(f"Retrieved stats for {member.id}: Level {level}, XP {xp}")
8990

91+
# Get server-specific settings from LevelsService
92+
enable_xp_cap = self.levels_service.enable_xp_cap.get(
93+
guild_id,
94+
self.levels_service.enable_xp_cap.get(0, False),
95+
)
96+
max_level = self.levels_service.get_max_level(guild_id)
97+
9098
level_display: int
9199
xp_display: str
92-
if self.levels_service.enable_xp_cap and level >= self.levels_service.max_level:
100+
if enable_xp_cap and level >= max_level:
93101
max_xp: float = self.levels_service.calculate_xp_for_level(
94-
self.levels_service.max_level,
102+
max_level,
103+
guild_id,
95104
)
96-
level_display = self.levels_service.max_level
105+
level_display = max_level
97106
xp_display = f"{round(max_xp)} (limit reached)"
98107
logger.debug(f"XP cap reached for {member.id}")
99108
else:
@@ -103,7 +112,11 @@ async def level(
103112
if CONFIG.XP_CONFIG.SHOW_XP_PROGRESS:
104113
xp_progress: int
105114
xp_required: int
106-
xp_progress, xp_required = self.levels_service.get_level_progress(xp, level)
115+
xp_progress, xp_required = self.levels_service.get_level_progress(
116+
xp,
117+
level,
118+
guild_id,
119+
)
107120
progress_bar: str = self.levels_service.generate_progress_bar(
108121
xp_progress,
109122
xp_required,

0 commit comments

Comments
 (0)