diff --git a/modules/spicepay/spicepay.py b/modules/spicepay/spicepay.py deleted file mode 100644 index b7fdc25..0000000 --- a/modules/spicepay/spicepay.py +++ /dev/null @@ -1,917 +0,0 @@ -# modules/spicepay/spicepay.py -import re -import time -import asyncio -from typing import List, Dict, Tuple, Optional -import discord -from discord.ext import commands -from discord import app_commands - -# Accept both for backward compatibility; display uses "Refiner" -VALID_ROLES = {"crawler_owner", "carrier_owner", "refiner_owner", "lsr_owner"} - -# ------------------------ emoji helpers ------------------------ - -def _emoji_str(bot: commands.Bot, emoji_id: Optional[int], fallback: str) -> str: - if emoji_id: - e = bot.get_emoji(int(emoji_id)) - if e: - return str(e) - return fallback - -# ------------------------ core math ------------------------ - -def _normalize_roles(raw: str) -> List[str]: - """ - Accepts flexible input; maps common aliases to canonical role keys. - New canonical: refiner_owner (old: lsr_owner still accepted). - """ - if not raw: - return [] - txt = raw.strip().lower() - tokens = re.split(r"[,\s]+", txt) - out = set() - for tok in tokens: - if not tok: - continue - t = tok.replace("-", "").replace("_", "") - if t in ("crawler", "cr", "craw", "crawlerowner"): - out.add("crawler_owner") - elif t in ("carrier", "ca", "car", "carrierowner"): - out.add("carrier_owner") - elif t in ("refiner", "refinery", "refineryowner", "refinerowner", "lsr", "lsrowner", "largespicerefinery"): - out.add("refiner_owner") - elif t in ("none", "no", "nil"): - return [] - return list(out) - -def calculate_spice_distribution( - spice_total: int, - participants: List[Dict], - *, - base_weight: float = 25.0, - carrier_bonus: float = 12.5, - crawler_bonus: float = 12.5, - lsr_cut_percent: float = 10.0, # config key name retained; UI calls it "refinery cut" - lsr_required: bool = True, # True for melange payouts -) -> Tuple[List[Dict], List[str]]: - errors: List[str] = [] - result: List[Dict] = [] - - if not isinstance(spice_total, int) or spice_total < 0: - return [], ["Spice total must be a non-negative integer."] - if base_weight < 0 or carrier_bonus < 0 or crawler_bonus < 0: - return [], ["Weights must be non-negative."] - if lsr_cut_percent < 0 or lsr_cut_percent > 100: - return [], ["Refinery cut percent must be between 0 and 100."] - - seen_names = set() - refiner_owners: List[str] = [] - cleaned: List[Dict] = [] - - for i, p in enumerate(participants): - if not isinstance(p, dict): - errors.append(f"Participant at index {i} is not a dict.") - continue - - name = p.get('name') - participation = p.get('participation', 0) - roles = p.get('roles', []) - - if not name or not isinstance(name, str): - errors.append(f"Participant at index {i} has an invalid or missing name.") - continue - name = name.strip() - if name in seen_names: - errors.append(f"Duplicate participant name: '{name}'.") - continue - seen_names.add(name) - - if isinstance(participation, str): - participation = participation.strip().replace("%", "") - try: - participation = float(participation) - except Exception: - errors.append(f"Participant '{name}' has invalid participation value.") - continue - - if not isinstance(participation, (int, float)) or not (0 <= participation <= 100): - errors.append(f"Participant '{name}' has invalid participation: {participation}. Must be 0–100.") - continue - - if isinstance(roles, str): - roles = _normalize_roles(roles) - if not isinstance(roles, list): - errors.append(f"Participant '{name}' has invalid roles payload.") - continue - - invalid = [r for r in roles if r not in VALID_ROLES] - if invalid: - errors.append(f"Participant '{name}' has invalid roles: {invalid}.") - continue - - if 'refiner_owner' in roles or 'lsr_owner' in roles: - refiner_owners.append(name) - - cleaned.append({'name': name, 'participation': float(participation), 'roles': roles}) - - # Refiner checks depend on payout mode - if lsr_required: - if len(refiner_owners) > 1: - errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only one is allowed.") - elif len(refiner_owners) == 0: - errors.append("No refiner owner found. Exactly one is required for melange payouts.") - else: - if len(refiner_owners) > 1: - errors.append(f"Multiple refiner owners detected: {refiner_owners}. Only zero or one is allowed for sand payouts.") - - if errors: - return [], errors - - refiner_owner = refiner_owners[0] if refiner_owners else None - refinery_cut = int(spice_total * (lsr_cut_percent / 100.0)) if (lsr_required and refiner_owner) else 0 - distributable = spice_total - refinery_cut - - # Compute weights - weights: Dict[str, float] = {} - total_weight = 0.0 - for p in cleaned: - w = float(base_weight) * (p['participation'] / 100.0) - if 'carrier_owner' in p['roles']: - w += float(carrier_bonus) - if 'crawler_owner' in p['roles']: - w += float(crawler_bonus) - weights[p['name']] = w - total_weight += w - - if total_weight <= 0: - return [], [ - "Total effective weight is zero. Increase participation or weighting factors " - "(e.g., raise base ×% and/or bonuses) so at least one participant has non-zero weight." - ] - - # Proportional allocation - spice_raw: Dict[str, int] = {} - total_floored = 0 - for p in cleaned: - name = p['name'] - w = weights.get(name, 0.0) - share = int((w / total_weight) * distributable) - spice_raw[name] = share - total_floored += share - - remainder = distributable - total_floored - if refiner_owner: - spice_raw[refiner_owner] = spice_raw.get(refiner_owner, 0) + refinery_cut + remainder - else: - top = max(spice_raw.items(), key=lambda kv: kv[1])[0] - spice_raw[top] += remainder - - result = [{ - 'name': p['name'], - 'spice': int(spice_raw.get(p['name'], 0)), - 'participation': p['participation'], - 'roles': p['roles'], - } for p in cleaned] - - return result, [] - -# ------------------------ formatting ------------------------ - -def _fmt_roles_short(roles: List[str]) -> str: - if not roles: - return "Escort" - short = [] - for r in roles: - if r in ("refiner_owner", "lsr_owner"): short.append("Refiner") - elif r == "crawler_owner": short.append("Crawler") - elif r == "carrier_owner": short.append("Carrier") - return ",".join(short) if short else "Escort" - -def _format_table(results: List[Dict], unit_header: str, unit_suffix: str) -> str: - rows = sorted(results, key=lambda r: (-r['spice'], r['name'].lower())) - any_zero = any((r.get('participation') or 0) <= 0 for r in rows) - - own_header = "Owner of/Role" - name_w = max(4, *(len(r['name']) for r in rows)) if rows else 4 - own_w = max(len(own_header), *(len(_fmt_roles_short(r['roles'])) for r in rows)) if rows else len(own_header) - pay_w = max(6, len(unit_header)) - - header = f"{'Name'.ljust(name_w)} {'Active %'.rjust(8)} {own_header.ljust(own_w)} {unit_header.rjust(pay_w)}" - sep = "-" * len(header) - lines = [header, sep] - - for r in rows: - name = r['name'].ljust(name_w) - pct_val = int(round(r.get('participation') or 0)) - pct = f"{pct_val}%" - if pct_val == 0: - pct += "*" # owner-only marker - pct = pct.rjust(8) - owned = _fmt_roles_short(r.get('roles', [])).ljust(own_w) - amount = f"{r['spice']} {unit_suffix}" - lines.append(f"{name} {pct} {owned} {amount}") - - if any_zero: - lines.append("") - lines.append("* 0% = owner only (did not actively join the run)") - return "```\n" + "\n".join(lines) + "\n```" - -# -------- Modals -------- - -class _SetupModal(discord.ui.Modal, title="Spice Pay — Setup"): - def __init__(self, cog: "SpicePayCog", pre_participants: Optional[int]): - super().__init__() - self.cog = cog - self.pre_participants = pre_participants - - self.total_field = discord.ui.TextInput( - label="Total spice yield (sand)", - placeholder="e.g. 12345", - required=True, - max_length=10 - ) - self.count_field = discord.ui.TextInput( - label="Participants (incl. owners)", - placeholder="Total number of people (add refiner/carrier/crawler owners too)", - required=True, - max_length=45, # allow a clearer hint - default=str(pre_participants) if pre_participants is not None else None - ) - - self.add_item(self.total_field) - self.add_item(self.count_field) - - async def on_submit(self, interaction: discord.Interaction): - try: - total = int(self.total_field.value.strip()) - if total < 0: - raise ValueError - except Exception: - return await interaction.response.send_message("❌ Total must be a non-negative integer.", ephemeral=True) - - try: - count = int(str(self.count_field.value).strip()) - if not (1 <= count <= 25): - raise ValueError - except Exception: - return await interaction.response.send_message("❌ Participants must be between 1 and 25.", ephemeral=True) - - await self.cog.start_session(interaction, total, count) - -class _ParticipantModal(discord.ui.Modal): - def __init__(self, cog: "SpicePayCog", session_key, index: int, existing: Optional[Dict]): - super().__init__(title=f"Participant {index}") - self.cog = cog - self.session_key = session_key - self.index = index - ex = existing or {} - - self.name = discord.ui.TextInput( - label="Name", - placeholder="Player name (or @mention text)", - required=True, - max_length=64, - default=ex.get("name") if ex else None - ) - self.part = discord.ui.TextInput( - label="Active participation % (0–100)", - placeholder="How much they actively joined the run (0..100; 0% = owner only)", - required=True, - max_length=45, - default=str(int(ex["participation"])) if "participation" in ex else None - ) - self.roles = discord.ui.TextInput( - label="Owner of (optional)", - placeholder="refiner, crawler, carrier — leave empty if escort/non-owner", - required=False, - max_length=64, - default=",".join(ex["roles"]) if ex and ex.get("roles") else None - ) - self.add_item(self.name) - self.add_item(self.part) - self.add_item(self.roles) - - async def on_submit(self, interaction: discord.Interaction): - sess = self.cog.sessions.get(self.session_key) - if not sess: - return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True) - - name = self.name.value.strip() - part_raw = str(self.part.value).strip().replace("%", "") - try: - participation = float(part_raw) - except Exception: - return await interaction.response.send_message("❌ Participation must be a number (0–100).", ephemeral=True) - if participation < 0 or participation > 100: - return await interaction.response.send_message("❌ Participation must be 0–100.", ephemeral=True) - - roles = _normalize_roles(self.roles.value or "") - - idx = self.index - 1 - while len(sess["participants"]) < idx + 1: - sess["participants"].append({}) - sess["participants"][idx] = {"name": name, "participation": participation, "roles": roles} - - if sess["next_index"] == self.index and sess["next_index"] < sess["count"]: - sess["next_index"] += 1 - - await self.cog.render_progress(interaction, self.session_key) - -class _MelangeModal(discord.ui.Modal, title="Set melange refinery yield"): - def __init__(self, cog: "SpicePayCog", session_key): - super().__init__() - self.cog = cog - self.session_key = session_key - self.amount = discord.ui.TextInput( - label="Melange refinery yield (integer)", - placeholder="e.g. 123", - required=True, - max_length=10 - ) - self.add_item(self.amount) - - async def on_submit(self, interaction: discord.Interaction): - sess = self.cog.sessions.get(self.session_key) - if not sess: - return await interaction.response.send_message("Session expired.", ephemeral=True) - val = self.amount.value.strip() - try: - m = int(val) - if m < 0: - raise ValueError - except Exception: - return await interaction.response.send_message("❌ Melange total must be a non-negative integer.", ephemeral=True) - sess["melange_total"] = m - await self.cog.render_progress(interaction, self.session_key) - -class _WeightsModal(discord.ui.Modal, title="Adjust weighting factors"): - def __init__(self, cog: "SpicePayCog", session_key): - super().__init__() - self.cog = cog - self.session_key = session_key - - sess = self.cog.sessions.get(session_key) or {} - w = (sess.get("weights") or {}) - bw = w.get("base", self.cog.base_weight) - cab = w.get("carrier", self.cog.carrier_bonus) - crb = w.get("crawler", self.cog.crawler_bonus) - lsr = w.get("lsr", self.cog.lsr_cut_percent) - - self.base = discord.ui.TextInput(label="Base × active %", required=False, max_length=10, placeholder=str(bw)) - self.carrier = discord.ui.TextInput(label="Carrier bonus (+)", required=False, max_length=10, placeholder=str(cab)) - self.crawler = discord.ui.TextInput(label="Crawler bonus (+)", required=False, max_length=10, placeholder=str(crb)) - self.lsr = discord.ui.TextInput(label="Refinery cut % (melange)", required=False, max_length=10, placeholder=str(lsr)) - - self.add_item(self.base) - self.add_item(self.carrier) - self.add_item(self.crawler) - self.add_item(self.lsr) - - async def on_submit(self, interaction: discord.Interaction): - sess = self.cog.sessions.get(self.session_key) - if not sess: - return await interaction.response.send_message("Session expired.", ephemeral=True) - - def _parse_nonneg(opt_str, pct=False): - if not opt_str: - return None - s = str(opt_str).strip().replace("%", "") - try: - v = float(s) - except Exception: - return "bad" - if v < 0: - return "bad" - if pct and (v < 0 or v > 100): - return "bad" - return v - - b = _parse_nonneg(self.base.value) - cab = _parse_nonneg(self.carrier.value) - crb = _parse_nonneg(self.crawler.value) - lsr = _parse_nonneg(self.lsr.value, pct=True) - - if "bad" in (b, cab, crb, lsr): - return await interaction.response.send_message( - "❌ Invalid values. Use non-negative numbers; refinery % must be 0–100.", - ephemeral=True - ) - - w = sess.get("weights") or {} - if b is not None: w["base"] = b - if cab is not None: w["carrier"] = cab - if crb is not None: w["crawler"] = crb - if lsr is not None: w["lsr"] = lsr - sess["weights"] = w - - # Soft warning for extreme values - if any(v is not None and v > 1000 for v in (b, cab, crb)): - try: - await interaction.response.send_message( - "⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.", - ephemeral=True - ) - except discord.InteractionResponded: - await interaction.followup.send( - "⚠️ Very large weights don’t change ratios (we normalize), but may affect rounding slightly.", - ephemeral=True - ) - return await self.cog.render_progress(interaction, self.session_key) - - await self.cog.render_progress(interaction, self.session_key) - -# ------------------------ Views ------------------------ - -class _HelpView(discord.ui.View): - def __init__(self, cog: "SpicePayCog", session_key: tuple): - super().__init__(timeout=600) # keep session alive - self.cog = cog - self.session_key = session_key - - async def on_timeout(self): - pass - - @discord.ui.button(label="Return to setup", style=discord.ButtonStyle.primary) - async def back(self, interaction: discord.Interaction, _btn: discord.ui.Button): - await self.cog.render_progress(interaction, self.session_key) - - @discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary) - async def open_weights(self, interaction: discord.Interaction, _btn: discord.ui.Button): - await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key)) - -class _WizardView(discord.ui.View): - def __init__(self, cog: "SpicePayCog", session_key: tuple): - super().__init__(timeout=600) - self.cog = cog - self.session_key = session_key - - async def on_timeout(self): - pass - - @discord.ui.button(label="Add / Edit participant", style=discord.ButtonStyle.primary, row=0) - async def add_edit(self, interaction: discord.Interaction, _button: discord.ui.Button): - sess = self.cog.sessions.get(self.session_key) - if not sess: - return await interaction.response.send_message("Session expired. Please run /spicepay again.", ephemeral=True) - idx = sess["next_index"] - if idx > sess["count"]: - idx = sess["count"] - existing = sess["participants"][idx-1] if 0 <= idx-1 < len(sess["participants"]) else None - await interaction.response.send_modal(_ParticipantModal(self.cog, self.session_key, idx, existing)) - - @discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary, row=0) - async def previous(self, interaction: discord.Interaction, _button: discord.ui.Button): - sess = self.cog.sessions.get(self.session_key) - if not sess: - return await interaction.response.send_message("Session expired.", ephemeral=True) - sess["next_index"] = max(1, sess["next_index"] - 1) - await self.cog.render_progress(interaction, self.session_key) - - @discord.ui.button(label="Next", style=discord.ButtonStyle.secondary, row=0) - async def next(self, interaction: discord.Interaction, _button: discord.ui.Button): - sess = self.cog.sessions.get(self.session_key) - if not sess: - return await interaction.response.send_message("Session expired.", ephemeral=True) - sess["next_index"] = min(sess["count"], sess["next_index"] + 1) - await self.cog.render_progress(interaction, self.session_key) - - @discord.ui.button(label="Toggle payout: Sand/Melange", style=discord.ButtonStyle.secondary, row=1) - async def toggle_payout(self, interaction: discord.Interaction, _button: discord.ui.Button): - sess = self.cog.sessions.get(self.session_key) - if not sess: - return await interaction.response.send_message("Session expired.", ephemeral=True) - sess["payout_type"] = "melange" if sess.get("payout_type") == "sand" else "sand" - if sess["payout_type"] == "melange" and not sess.get("melange_total"): - return await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key)) - await self.cog.render_progress(interaction, self.session_key) - - @discord.ui.button(label="Set melange refinery yield", style=discord.ButtonStyle.primary, row=1) - async def set_melange(self, interaction: discord.Interaction, _button: discord.ui.Button): - await interaction.response.send_modal(_MelangeModal(self.cog, self.session_key)) - - @discord.ui.button(label="Adjust weighting factors", style=discord.ButtonStyle.secondary, row=1) - async def adjust_weights(self, interaction: discord.Interaction, _button: discord.ui.Button): - await interaction.response.send_modal(_WeightsModal(self.cog, self.session_key)) - - @discord.ui.button(label="Weights help", style=discord.ButtonStyle.secondary, row=2) - async def weights_help(self, interaction: discord.Interaction, _button: discord.ui.Button): - help_txt = ( - "**How the split works**\n" - "• **Participants are people** (or a guild as one person). Vehicles/refinery are **owned** by someone.\n" - "• Everyone gets a *weight*: **Base × active %** + **bonuses** (Carrier/Crawler if they own them).\n" - "• We split the pot **proportionally** to those weights (normalized; only ratios matter).\n" - "• **Melange payout** only: a **refinery cut %** is taken first and given to the **Refiner owner**.\n" - "• People with **0%** are treated as **owner only** (get owner bonuses/cut but didn’t actively join the run).\n" - "• Rounding leftovers go to the Refiner (melange) or the top earner (sand).\n\n" - "_Tap **Return to setup** below to go back, or **Adjust weighting factors** to change numbers._" - ) - try: - await interaction.response.edit_message(content=help_txt, view=_HelpView(self.cog, self.session_key)) - except discord.InteractionResponded: - await interaction.followup.send(help_txt, ephemeral=True, view=_HelpView(self.cog, self.session_key)) - - # ---------- Presets row ---------- - @discord.ui.button(label="Preset: Owner-heavy", style=discord.ButtonStyle.secondary, row=3) - async def preset_owner(self, interaction: discord.Interaction, _btn: discord.ui.Button): - await self.cog.apply_preset(interaction, self.session_key, preset="owner") - - @discord.ui.button(label="Preset: Participation-heavy", style=discord.ButtonStyle.secondary, row=3) - async def preset_part(self, interaction: discord.Interaction, _btn: discord.ui.Button): - await self.cog.apply_preset(interaction, self.session_key, preset="participation") - - @discord.ui.button(label="Preset: Fair (server defaults)", style=discord.ButtonStyle.secondary, row=3) - async def preset_fair(self, interaction: discord.Interaction, _btn: discord.ui.Button): - await self.cog.apply_preset(interaction, self.session_key, preset="fair") - - @discord.ui.button(label="Preset: Even split", style=discord.ButtonStyle.secondary, row=3) - async def preset_even(self, interaction: discord.Interaction, _btn: discord.ui.Button): - await self.cog.apply_preset(interaction, self.session_key, preset="even") - - @discord.ui.button(label="Save as my defaults", style=discord.ButtonStyle.success, row=3) - async def save_defaults(self, interaction: discord.Interaction, _btn: discord.ui.Button): - await self.cog.save_user_defaults(interaction, self.session_key) - - @discord.ui.button(label="Finish", style=discord.ButtonStyle.success, row=2) - async def finish(self, interaction: discord.Interaction, _button: discord.ui.Button): - await self.cog.finish_and_show(interaction, self.session_key) - - @discord.ui.button(label="Cancel", style=discord.ButtonStyle.danger, row=2) - async def cancel(self, interaction: discord.Interaction, _button: discord.ui.Button): - self.cog.sessions.pop(self.session_key, None) - await interaction.response.edit_message(content="Spice pay session cancelled.", view=None) - -class _StartView(discord.ui.View): - def __init__(self, cog: "SpicePayCog", participants: Optional[int]): - super().__init__(timeout=600) # was 120 - self.cog = cog - self.participants = participants - - @discord.ui.button(label="Open setup", style=discord.ButtonStyle.primary) - async def open_setup(self, interaction: discord.Interaction, _btn: discord.ui.Button): - try: - await interaction.response.send_modal(_SetupModal(self.cog, self.participants)) - except Exception as e: - print("[spicepay] fallback button failed:", repr(e)) - await interaction.response.send_message("Still couldn’t open the modal. Do I have the right permissions here?", ephemeral=True) - -# ------------------------ Cog ------------------------ - -class SpicePayCog(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.sessions: Dict[tuple, Dict] = {} - - cfg = bot.config['DEFAULT'] - def _f(key, default): - try: - return float(cfg.get(key, str(default))) - except Exception: - return float(default) - self.base_weight = _f('spicepay_base_weight', 25.0) - self.carrier_bonus = _f('spicepay_carrier_bonus', 12.5) - self.crawler_bonus = _f('spicepay_crawler_bonus', 12.5) - self.lsr_cut_percent = _f('spicepay_lsr_cut_percent', 10.0) # keep key; UI calls it refinery cut - - def _i(key): - try: - return int(cfg.get(key)) if cfg.get(key) else None - except Exception: - return None - self.emoji_sand_id = _i('emoji_sand_id') - self.emoji_melange_id = _i('emoji_melange_id') - self.emoji_cc_id = _i('emoji_carrier_crawler_id') - - # Session reaper (clean up sessions older than 60 minutes) - self._reaper_task = asyncio.create_task(self._session_reaper()) - - def cog_unload(self): - try: - self._reaper_task.cancel() - except Exception: - pass - - async def _session_reaper(self): - await self.bot.wait_until_ready() - while not self.bot.is_closed(): - now = time.time() - ttl = 3600 # 60 min - dead = [k for k, s in list(self.sessions.items()) - if now - s.get("created_ts", now) > ttl] - for k in dead: - self.sessions.pop(k, None) - await asyncio.sleep(300) # sweep every 5 min - - # ----- user prefs helpers (persisted to data_file) ----- - - def _get_user_prefs(self, user_id: int) -> Optional[Dict]: - prefs = self.bot.data_manager.get('spicepay_prefs') - for row in prefs: - if row.get('user_id') == user_id: - return row - return None - - def _save_user_prefs(self, user_id: int, weights: Dict[str, float]): - # overwrite existing - self.bot.data_manager.remove('spicepay_prefs', lambda r: r.get('user_id') == user_id) - self.bot.data_manager.add('spicepay_prefs', { - 'user_id': user_id, - 'weights': { - 'base': float(weights.get('base', self.base_weight)), - 'carrier': float(weights.get('carrier', self.carrier_bonus)), - 'crawler': float(weights.get('crawler', self.crawler_bonus)), - 'lsr': float(weights.get('lsr', self.lsr_cut_percent)), - }, - 'saved_ts': time.time() - }) - - # ----- commands ----- - - @app_commands.command(name="spicepay", description="Calculate spice payout via a guided wizard (opens a form)") - @app_commands.describe( - participants="Total number of people involved (include owners of refiner/carrier/crawler)", - force_new="Start a fresh session even if one is active" - ) - async def spicepay(self, interaction: discord.Interaction, participants: Optional[int] = None, force_new: Optional[bool] = False): - key = (interaction.guild.id if interaction.guild else 0, interaction.user.id) - if not force_new and key in self.sessions: - return await self.render_progress(interaction, key, new_message=True) - - try: - await interaction.response.send_modal(_SetupModal(self, participants)) - except Exception as e: - print("[spicepay] send_modal failed:", repr(e)) - try: - await interaction.response.defer(ephemeral=True, thinking=False) - except Exception: - pass - await interaction.followup.send( - "Couldn’t open the setup modal automatically. Click below to start:", - ephemeral=True, - view=_StartView(self, participants) - ) - - @app_commands.command(name="spicepay_resume", description="Reopen your active spice pay session") - async def spicepay_resume(self, interaction: discord.Interaction): - key = (interaction.guild.id if interaction.guild else 0, interaction.user.id) - if key in self.sessions: - await self.render_progress(interaction, key, new_message=True) - else: - await interaction.response.send_message( - "No active session found. Run **/spicepay** to start a new one.", - ephemeral=True - ) - - @app_commands.command(name="spicepay_cancel", description="Cancel your active spicepay session") - async def spicepay_cancel(self, interaction: discord.Interaction): - key = (interaction.guild.id if interaction.guild else 0, interaction.user.id) - if key in self.sessions: - self.sessions.pop(key, None) - await interaction.response.send_message("Cancelled your spice pay session.", ephemeral=True) - else: - await interaction.response.send_message("No active spice pay session.", ephemeral=True) - - @app_commands.command(name="spicepay_config", description="Show the current spicepay weight settings") - async def spicepay_config(self, interaction: discord.Interaction): - txt = ( - "**SpicePay configuration**\n" - f"- Refinery cut (melange payouts): **{self.lsr_cut_percent}%**\n" - f"- Base weight: **{self.base_weight} × active %**\n" - f"- Carrier bonus: **+{self.carrier_bonus}**\n" - f"- Crawler bonus: **+{self.crawler_bonus}**\n\n" - "_Edit these in `settings.conf` under `[DEFAULT]` and restart the bot._" - ) - await interaction.response.send_message(txt, ephemeral=True) - - # ----- session helpers ----- - - async def start_session(self, interaction: discord.Interaction, total: int, count: int): - g = interaction.guild - key = (g.id if g else 0, interaction.user.id) - - # seed weights from user prefs if present - w = {} - user_prefs = self._get_user_prefs(interaction.user.id) - if user_prefs and isinstance(user_prefs.get('weights'), dict): - w = { - 'base': float(user_prefs['weights'].get('base', self.base_weight)), - 'carrier': float(user_prefs['weights'].get('carrier', self.carrier_bonus)), - 'crawler': float(user_prefs['weights'].get('crawler', self.crawler_bonus)), - 'lsr': float(user_prefs['weights'].get('lsr', self.lsr_cut_percent)), - } - - # Auto-fill initiator as participant #1 (easy to adjust) - display = interaction.user.display_name if isinstance(interaction.user, discord.Member) else interaction.user.name - participants_seed = [{'name': display, 'participation': 50.0, 'roles': []}] - - self.sessions[key] = { - "total": total, # sand yield - "count": count, - "participants": participants_seed, - "next_index": 1, # stays on 1 so they can review/edit their autofill - "created_ts": time.time(), - "payout_type": "sand", # 'sand' or 'melange' - "melange_total": None, # required if payout_type == 'melange' - "weights": w # optional overrides: base, carrier, crawler, lsr(refinery) - } - await self.render_progress(interaction, key, new_message=True) - - async def render_progress(self, interaction: discord.Interaction, key: tuple, new_message: bool = False): - sess = self.sessions.get(key) - if not sess: - return await interaction.response.send_message("Session expired.", ephemeral=True) - - sand_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️") - mel_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪") - cc_emoji = _emoji_str(self.bot, self.emoji_cc_id, "🚛") - - w = sess.get("weights") or {} - bw = w.get("base", self.base_weight) - cab = w.get("carrier", self.carrier_bonus) - crb = w.get("crawler", self.crawler_bonus) - lsrp = w.get("lsr", self.lsr_cut_percent) - - filled = sum(1 for p in sess["participants"] if p.get("name")) - idx = sess["next_index"] - total_pct = int(round(sum((p.get("participation") or 0) for p in sess["participants"]))) - - payout_line = f"Payout: **Sand Spice** {sand_emoji}" - if sess.get("payout_type") == "melange": - if sess.get("melange_total") is None: - payout_line = f"Payout: **Spice Melange** {mel_emoji} — _set melange refinery yield_" - else: - payout_line = f"Payout: **Spice Melange** {mel_emoji} — total **{sess['melange_total']}**" - - progress = ( - f"**Spice Pay Setup** — sand yield: **{sess['total']}** {sand_emoji} | participants: **{sess['count']}** | " - f"total active ≈ **{total_pct}%**\n" - f"{payout_line}\n" - f"Filled: **{filled}/{sess['count']}** — next: **{idx}**\n" - f"_Weighting factors: Refinery cut {lsrp}% (melange only) | Base {bw}× active % | Carrier +{cab} | Crawler +{crb} — " - f"normalized; only ratios matter._ {cc_emoji}" - ) - - preview_lines = [] - for i in range(sess["count"]): - p = sess["participants"][i] if i < len(sess["participants"]) else None - if p and p.get("name"): - mark = " (owner only)" if int(round(p.get("participation") or 0)) == 0 else "" - preview_lines.append(f"{i+1}. {p['name']} — {int(p['participation'])}%{mark} — {_fmt_roles_short(p.get('roles', []))}") - else: - preview_lines.append(f"{i+1}. _empty_") - - content = progress + "\n" + "\n".join(preview_lines) - view = _WizardView(self, key) - - try: - if new_message: - await interaction.response.send_message(content, ephemeral=True, view=view) - else: - await interaction.response.edit_message(content=content, view=view) - except discord.InteractionResponded: - await interaction.followup.send(content, ephemeral=True, view=view) - - async def apply_preset(self, interaction: discord.Interaction, key: tuple, preset: str): - sess = self.sessions.get(key) - if not sess: - return await interaction.response.send_message("Session expired.", ephemeral=True) - - if preset == "owner": - w = {'base': 15.0, 'carrier': 25.0, 'crawler': 25.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)} - elif preset == "participation": - w = {'base': 50.0, 'carrier': 5.0, 'crawler': 5.0, 'lsr': sess.get('weights', {}).get('lsr', self.lsr_cut_percent)} - elif preset == "even": - w = {'base': 1.0, 'carrier': 0.0, 'crawler': 0.0, 'lsr': 0.0} - else: # "fair" -> server defaults - w = {'base': self.base_weight, 'carrier': self.carrier_bonus, 'crawler': self.crawler_bonus, 'lsr': self.lsr_cut_percent} - - sess['weights'] = w - await self.render_progress(interaction, key) - - async def save_user_defaults(self, interaction: discord.Interaction, key: tuple): - sess = self.sessions.get(key) - if not sess: - return await interaction.response.send_message("Session expired.", ephemeral=True) - w = sess.get('weights') or { - 'base': self.base_weight, - 'carrier': self.carrier_bonus, - 'crawler': self.crawler_bonus, - 'lsr': self.lsr_cut_percent - } - self._save_user_prefs(interaction.user.id, w) - try: - await interaction.response.send_message("Saved these weighting factors as your defaults ✅", ephemeral=True) - except discord.InteractionResponded: - await interaction.followup.send("Saved these weighting factors as your defaults ✅", ephemeral=True) - - async def finish_and_show(self, interaction: discord.Interaction, key: tuple): - sess = self.sessions.get(key) - if not sess: - return await interaction.response.send_message("Session expired.", ephemeral=True) - - payout_type = sess.get("payout_type", "sand") - if payout_type == "melange" and sess.get("melange_total") is None: - return await interaction.response.send_message("❌ Set the melange refinery yield first.", ephemeral=True) - - w = sess.get("weights") or {} - bw = w.get("base", self.base_weight) - cab = w.get("carrier", self.carrier_bonus) - crb = w.get("crawler", self.crawler_bonus) - lsrp = w.get("lsr", self.lsr_cut_percent) - - participants = [] - names_seen = set() - for i in range(sess["count"]): - p = sess["participants"][i] if i < len(sess["participants"]) else {} - name = (p.get("name") or f"User{i+1}").strip() - if name in names_seen: - name = f"{name}_{i+1}" - names_seen.add(name) - part = p.get("participation", 0) - roles = p.get("roles", []) - participants.append({"name": name, "participation": part, "roles": roles}) - - if payout_type == "sand": - total = int(sess["total"]) - lsr_req = False - lsr_pct = 0.0 - unit_header = "Sand Spice" - unit_suffix = "Sand Spice" - title_emoji = _emoji_str(self.bot, self.emoji_sand_id, "🏜️") - payout_name = "Sand Spice" - else: - total = int(sess["melange_total"]) - lsr_req = True - lsr_pct = float(lsrp) - unit_header = "Spice Melange" - unit_suffix = "Spice Melange" - title_emoji = _emoji_str(self.bot, self.emoji_melange_id, "🧪") - payout_name = "Spice Melange" - - results, errors = calculate_spice_distribution( - total, - participants, - base_weight=bw, - carrier_bonus=cab, - crawler_bonus=crb, - lsr_cut_percent=lsr_pct, - lsr_required=lsr_req - ) - if errors: - err_text = "❌ **Problems found:**\n" + "\n".join(f"- {e}" for e in errors) + "\n\n" \ - "Use **Add / Edit participant** to fix, or **Cancel**." - try: - await interaction.response.edit_message(content=err_text, view=_WizardView(self, key)) - except discord.InteractionResponded: - await interaction.followup.send(err_text, ephemeral=True, view=_WizardView(self, key)) - return - - table = _format_table(results, unit_header, unit_suffix) - - class _PostView(discord.ui.View): - def __init__(self, outer: "SpicePayCog"): - super().__init__(timeout=600) - self.outer = outer - - @discord.ui.button(label="Post to channel", style=discord.ButtonStyle.primary) - async def post(self, inter: discord.Interaction, _btn: discord.ui.Button): - header = f"**Payout: {payout_name}** {title_emoji}" - settings = f"_Base {bw}× active % | Carrier +{cab} | Crawler +{crb}_" - if payout_type == "melange": - settings = f"_Refinery cut {lsrp}% | " + settings - txt = f"{header}\n{settings}\n{table}" - await inter.channel.send(txt) - await inter.response.edit_message(content="Posted to channel ✅", view=None) - - @discord.ui.button(label="Back", style=discord.ButtonStyle.secondary) - async def back(self, inter: discord.Interaction, _btn: discord.ui.Button): - await self.outer.render_progress(inter, key) - - content = f"**Preview** (not posted): **Payout: {payout_name}** {title_emoji}\n{table}" - try: - await interaction.response.edit_message(content=content, view=_PostView(self)) - except discord.InteractionResponded: - await interaction.followup.send(content=content, ephemeral=True, view=_PostView(self)) - -# ------------------------ setup ------------------------ - -async def setup(bot): - cog = SpicePayCog(bot) - await bot.add_cog(cog) - - try: - home_gid = int(bot.config['DEFAULT'].get('home_guild_id', '0')) - except Exception: - home_gid = 0 - - if home_gid: - guild_obj = discord.Object(id=home_gid) - bot.tree.add_command(cog.spicepay, guild=guild_obj) - bot.tree.add_command(cog.spicepay_resume, guild=guild_obj) - bot.tree.add_command(cog.spicepay_cancel, guild=guild_obj) - bot.tree.add_command(cog.spicepay_config, guild=guild_obj) - else: - bot.tree.add_command(cog.spicepay) - bot.tree.add_command(cog.spicepay_resume) - bot.tree.add_command(cog.spicepay_cancel) - bot.tree.add_command(cog.spicepay_config)