shaiwatcher/modules/pirate_cards/pirate_cards.py
2025-08-09 14:29:00 +02:00

227 lines
8.6 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 # use your configured moderator roles
class PirateCardsCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
cfg = bot.config['DEFAULT']
self.pirates_channel_id = int(cfg['pirates_list_channel_id'])
self.modlog_channel_id = int(cfg.get('modlog_channel_id', '0')) if cfg.get('modlog_channel_id') else 0
# thresholds / samples (optional, with defaults)
try:
self.group_threshold = int(cfg.get('threat_group_threshold', '3'))
except Exception:
self.group_threshold = 3
try:
self.min_samples = int(cfg.get('threat_min_samples_for_stats', '3'))
except Exception:
self.min_samples = 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)
# guard numeric fields
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="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))