918 lines
40 KiB
Python
918 lines
40 KiB
Python
# 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)
|