shaiwatcher/modules/spicepay/spicepay.py
Franz Rolfsvaag 5368d21be4 0.3.9.2.a5
performance improvements, stability, and primarily settings-handling improvements.
  - Due to the container transition, some settings handling became quietly broken or defunct.
2025-08-10 20:23:09 +02:00

916 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# modules/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 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] = {}
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(
"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"
"_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)