shaiwatcher/modules/pirate_cards/pirate_cards.py
Franz Rolfsvaag 21f6150842 0.3.9.7.a4
- Fixed permissions module not affecting certain features
- Added clear `[MOD]` labels to moderator-only commands for clarity
2025-08-11 09:53:35 +02:00

226 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# modules/pirate_cards/pirate_cards.py
import asyncio
import discord
from discord.ext import commands
from datetime import datetime
from mod_perms import require_mod_ctx
from modules.common.settings import cfg # ENV-first config helper
class PirateCardsCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
r = cfg(bot)
# IDs / config (ENV -> optional INI fallback)
self.pirates_channel_id = r.int('pirates_list_channel_id', 0)
self.modlog_channel_id = r.int('modlog_channel_id', 0)
# thresholds / samples (with defaults)
self.group_threshold = r.int('threat_group_threshold', 3)
self.min_samples = r.int('threat_min_samples_for_stats', 3)
# safe posting (dont ping)
self._no_mentions = discord.AllowedMentions.none()
# serialize rebuilds per guild
self._locks: dict[int, asyncio.Lock] = {}
# -------- internals / helpers --------
def _lock_for(self, guild_id: int) -> asyncio.Lock:
self._locks.setdefault(guild_id, asyncio.Lock())
return self._locks[guild_id]
@staticmethod
def _esc(s: str) -> str:
"""Escape markdown & neutralize mentions for any user-sourced strings we show."""
safe = discord.utils.escape_markdown(str(s))
return safe.replace("@", "@\u200b")
def _color_for_threat(self, threat: int, enc_count: int) -> discord.Color:
"""
Gradient:
- If too few samples: dark gray (unknown)
- Else 0 -> green (0,255,0), 100 -> red (255,0,0)
"""
if enc_count < self.min_samples:
return discord.Color.dark_gray()
t = max(0, min(100, int(threat))) / 100.0
r = int(round(255 * t))
g = int(round(255 * (1.0 - t)))
return discord.Color.from_rgb(r, g, 0)
def _bucket(self, rate: float, samples: int) -> str:
if samples < self.min_samples:
return "unknown"
if rate <= 0:
return "never"
if rate <= 0.25:
return "rarely"
if rate <= 0.60:
return "sometimes"
if rate <= 0.85:
return "often"
return "always"
def _encounters_for(self, pirate: dict):
acct_l = str(pirate.get('account_name', '')).lower()
char_l = str(pirate.get('character_name', '')).lower()
out = []
for e in self.bot.data_manager.get('encounters'):
try:
ident = str(e.get('identifier', '')).lower()
if ident in (acct_l, char_l):
out.append(e)
except Exception:
continue
return out
def _get_card_record(self, account_lower: str):
for r in self.bot.data_manager.get('pirate_cards'):
if r.get('account_lower') == account_lower:
return r
return None
async def _modlog(self, guild: discord.Guild, content: str):
if not self.modlog_channel_id:
return
ch = guild.get_channel(self.modlog_channel_id)
if ch:
try:
await ch.send(content, allowed_mentions=self._no_mentions)
except Exception:
pass
async def _build_embed(self, pirate: dict) -> discord.Embed:
encs = self._encounters_for(pirate)
total = len(encs)
def _i(v, d=0):
try:
return int(v)
except Exception:
return d
group_rate = (sum(1 for e in encs if _i(e.get('group_size'), 0) >= self.group_threshold) / total) if total else 0.0
destroy_rate = (sum(1 for e in encs if bool(e.get('destruction'))) / total) if total else 0.0
# last encountered: date only (UTC)
last_date = ""
if total:
try:
last_ts = max(int(float(e.get('timestamp', 0))) for e in encs)
last_date = datetime.utcfromtimestamp(last_ts).strftime('%Y-%m-%d')
except Exception:
last_date = ""
groups_str = self._bucket(group_rate, total)
destr_str = self._bucket(destroy_rate, total)
# threat & color
threat = int(pirate.get('threat_level', 0))
color = self._color_for_threat(threat, total)
char = self._esc(pirate.get('character_name', 'Unknown'))
acct = self._esc(pirate.get('account_name', 'Unknown#00000'))
embed = discord.Embed(title=char, color=color)
embed.add_field(name="Account name", value=f"*{acct}*", inline=False)
embed.add_field(name="Threat", value=f"{threat}%", inline=True)
embed.add_field(name="In groups", value=groups_str, inline=True)
embed.add_field(name="Destructive", value=destr_str, inline=True)
embed.set_footer(text=f"Encounters: {total} | Last: {last_date}")
return embed
async def refresh_card_for_account(self, guild: discord.Guild, account_name: str):
"""Create or update a single pirate card by account name."""
acct_l = str(account_name or "").lower()
pirates = self.bot.data_manager.get('pirates')
p = next((x for x in pirates if str(x.get('account_name', '')).lower() == acct_l), None)
if not p:
return
channel = guild.get_channel(self.pirates_channel_id)
if not channel:
return
embed = await self._build_embed(p)
rec = self._get_card_record(acct_l)
if rec:
try:
msg = await channel.fetch_message(int(rec['message_id']))
await msg.edit(embed=embed)
return
except Exception:
# fall-through to recreate
pass
try:
msg = await channel.send(embed=embed, allowed_mentions=self._no_mentions)
self.bot.data_manager.add('pirate_cards', {
'account_lower': acct_l,
'message_id': msg.id
})
except Exception:
pass
async def delete_card_for_account(self, guild: discord.Guild, account_name: str):
"""Delete a pirate card by account and remove its record."""
acct_l = str(account_name or "").lower()
channel = guild.get_channel(self.pirates_channel_id)
rec = self._get_card_record(acct_l)
if rec and channel:
try:
msg = await channel.fetch_message(int(rec['message_id']))
await msg.delete()
except Exception:
pass
# ensure record is gone regardless
self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == acct_l)
async def update_on_edit(self, guild: discord.Guild, old_account: str, new_account: str):
"""When an account name changes, carry over the card mapping and refresh."""
old_l = str(old_account or "").lower()
new_l = str(new_account or "").lower()
rec = self._get_card_record(old_l)
if rec:
# replace mapping to new key
self.bot.data_manager.remove('pirate_cards', lambda r: r.get('account_lower') == old_l)
self.bot.data_manager.add('pirate_cards', {
'account_lower': new_l,
'message_id': rec['message_id']
})
await self.refresh_card_for_account(guild, new_account)
# -------- command (mod-gated via require_mod_ctx) --------
@commands.hybrid_command(name="pirate_cards_rebuild", description="[MOD] Rebuild pirate cards for all known pirates")
async def pirate_cards_rebuild(self, ctx: commands.Context):
if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
return
if not ctx.guild:
return await ctx.reply("Use this in a server.", ephemeral=True)
async with self._lock_for(ctx.guild.id):
guild = ctx.guild
ch = guild.get_channel(self.pirates_channel_id)
if not ch:
return await ctx.reply("Configured pirates_list_channel_id not found.", ephemeral=True)
count = 0
for p in self.bot.data_manager.get('pirates'):
try:
await self.refresh_card_for_account(guild, p.get('account_name', ''))
count += 1
except Exception:
continue
is_slash = hasattr(ctx, "interaction") and ctx.interaction is not None
await ctx.reply(f"Rebuilt/updated {count} pirate cards.", ephemeral=is_slash)
async def setup(bot):
await bot.add_cog(PirateCardsCog(bot))