Delete modules/spicepay/spicepay.py

This commit is contained in:
frarol96 2025-08-10 15:12:13 +00:00
parent a14472833f
commit a381c86b05

View File

@ -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 0100.")
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 % (0100)",
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 (0100).", ephemeral=True)
if participation < 0 or participation > 100:
return await interaction.response.send_message("❌ Participation must be 0100.", 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 0100.",
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 dont change ratios (we normalize), but may affect rounding slightly.",
ephemeral=True
)
except discord.InteractionResponded:
await interaction.followup.send(
"⚠️ Very large weights dont 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 didnt 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 couldnt 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(
"Couldnt 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)