# 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 from modules.common.settings import cfg # 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] = {} r = cfg(bot) def _f(key, default): try: return float(r.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: v = r.get(key, "") return int(v) if v 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" "_Set via environment variables or your INI. Restart the bot after changing._" ) 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) home_gid = cfg(bot).int('home_guild_id', 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)