rollback checkpoint: hardened gacha, disabled ffmpeg, deleted rio, async db

This commit is contained in:
esef committed 2026-08-03 20:05:45 +03:00
1 parent 05c0599d1c
commit 30efb9105f
9 files changed
+367 -260

No files matched your search

+2
View File
@@ -19,7 +19,9 @@ _get_id.py
AGENTS.md
firebase-service-account.json
firebase_service_account.json
firebase_service_account_test.json
**/.codewhale/*
!**/.codewhale/constitution.json
.deepseek/
radio_check.sh
TECH_DEBT.md
+2 -6
View File
@@ -13,6 +13,7 @@ GACHA_ANIM_DURATION = 3.5 # 195 frames × 100 ms (used to be 19.5 will be chang
# ── APIs ────────────────────────────────────────────────────────────────────
BANNER_API = "https://api.ennead.cc/buruaka/banner"
CHARACTER_API = "https://api.ennead.cc/buruaka/character"
JOEXYZ_CDN = "https://ba.joexyz.online/cdn/v2/images"
# ── Gacha rates (matching real Blue Archive) ────────────────────────────────
@@ -67,7 +68,6 @@ PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4
# ── Inventory display ───────────────────────────────────────────────────────
PAGE_SIZE = 10
POOL_TOTALS = {3: 106, 2: 22, 1: 11} # standard-pool size for progress bars
FILTER_LABELS: dict[str, int | None] = {"All": None, "3★": 3, "2★": 2, "1★": 1}
# ── CDN icon slug mapping (SchaleDB variant suffix → joexyz CDN slug suffix) ─
@@ -109,12 +109,8 @@ def cdn_icon_slug(name: str) -> str:
return name.lower().replace(" ", "_")
# ── Font paths ──────────────────────────────────────────────────────────────
# ── Font paths (tried in order; Pillow default used as last resort) ─────────
FONT_PATHS = [
"C:/Windows/Fonts/segoeui.ttf",
"C:/Windows/Fonts/segoeuib.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/arialbd.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
]
+150 -6
View File
@@ -13,6 +13,7 @@ import aiohttp
from .constants import (
BUNDLED_DB,
BANNER_API,
CHARACTER_API,
JOEXYZ_CDN,
cdn_icon_slug,
BANNER_RATES,
@@ -79,11 +80,140 @@ class StudentDB:
excluded,
)
async def merge_ennead(self, banner_cache: dict) -> int:
"""Merge characters from the ennead.cc API that aren't in the SchaleDB.
Fetches the full character list, maps fields to SchaleDB format, and
derives IsLimited from banner history. Only characters with IDs not
already present are added — existing SchaleDB entries are never touched.
Must be called after load() and after banners have been fetched.
Returns the number of new characters merged.
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(CHARACTER_API, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
log.warning("Ennead character API returned %d", resp.status)
return 0
chars = await resp.json()
except Exception:
log.exception("Failed to fetch ennead character API")
return 0
# Build IsLimited from all known banners (0=standard, 1=limited)
is_limited = self._build_is_limited(banner_cache)
try:
added = self._merge_chars(chars, is_limited)
if added:
log.info(
"Merged %d new characters from ennead API (total: %d, std: %d, limited: %d)",
added,
len(self.students),
sum(len(v) for v in self._std_pool.values()),
len(self._limited_pool),
)
except Exception:
log.exception("Failed to merge ennead characters")
return added
def _merge_chars(self, chars: list[dict], is_limited: dict[str, int]) -> int:
"""Add characters from the ennead API payload that aren't already loaded."""
added = 0
for c in chars:
sid = c["id"]
if sid in self.by_id:
continue # already present from SchaleDB — keep original
name = c["name"]
rarity = c["baseStar"]
limit = is_limited.get(name.lower())
if limit is None:
# Never appeared as a banner rate-up.
# 1-2★: welfare (matches SchaleDB: 19/20 welfare are 1★)
# 3★: likely upcoming limited (all known limited chars are 3★)
limit = 2 if rarity <= 2 else 1
student = {
"Id": sid,
"Name": name,
"StarGrade": rarity,
"School": c.get("school", "Unknown"),
"IsLimited": limit,
"SquadType": "Main" if c.get("squadType") == "Striker" else "Support",
"DevName": "",
}
self.by_id[sid] = student
self.by_name[name.lower()] = student
self.students.append(student)
if limit < 2:
if rarity in self.by_rarity:
self.by_rarity[rarity].append(student)
if limit == 0 and rarity in self._std_pool:
self._std_pool[rarity].append(student)
elif limit == 1:
self._limited_pool.append(student)
added += 1
return added
@staticmethod
def _build_is_limited(banner_cache: dict) -> dict[str, int]:
"""Derive IsLimited from banner history.
Returns {name_lower: 0|1} where:
0 = standard (ever appeared in a PickupGacha)
1 = limited (only in LimitedGacha / FesGacha, or never a rate-up)
"""
result: dict[str, int] = {}
all_banners = (
banner_cache.get("current", [])
+ banner_cache.get("upcoming", [])
+ banner_cache.get("ended", [])
)
for b in all_banners:
gtype = b.get("gachaType", "")
is_pickup = gtype == "PickupGacha"
for name in b.get("rateups", []):
key = name.lower()
# PickupGacha takes priority — once standard, always standard
if key not in result or is_pickup:
result[key] = 0 if is_pickup else 1
return result
@property
def pool_totals(self) -> dict[int, int]:
"""Total collectible students by rarity (excludes welfare)."""
return {r: len(v) for r, v in self.by_rarity.items()}
def get(self, student_id: int) -> Optional[dict]:
return self.by_id.get(student_id)
def get_by_name(self, name: str) -> Optional[dict]:
return self.by_name.get(name.lower())
"""Resolve a student by name with fuzzy fallback.
Tries: exact match → strip variant suffix → substring match.
Handles banner-API names like "Maki (Camp)" when only "Maki" exists.
"""
key = name.lower().strip()
if key in self.by_name:
return self.by_name[key]
# "Maki (Camp)" → try "Maki"
if "(" in key:
base = key.split("(", 1)[0].strip()
if base in self.by_name:
return self.by_name[base]
# Last resort: substring match (e.g. "Yuuka Pajamas" → "Yuuka (Pajamas)")
for stored_name, student in self.by_name.items():
if key in stored_name or stored_name in key:
return student
return None
def build_pool(self, banner: Optional[dict]) -> dict[int, list[dict]]:
"""Build the pullable character pool for a specific banner.
@@ -134,13 +264,21 @@ class StudentDB:
For 1-2★: uniform random (rate-ups don't affect lower rarities).
"""
if not pool:
return random.choice(self.students)
# Fallback: any non-welfare student (shouldn't normally happen)
pullable = [s for s in self.students if s.get("IsLimited", 0) < 2]
return random.choice(pullable or self.students)
if rarity == 3 and rateup_names:
# Weighted: rate-up chars get 3x weight each vs normal chars
# Weighted: rate-up chars get 3x weight each vs normal chars.
# Use get_by_name so "Hoshino (Armed)" in API matches "Hoshino" in DB.
rateup_ids = set()
for rn in rateup_names:
rs = self.get_by_name(rn)
if rs:
rateup_ids.add(rs["Id"])
weights = []
for s in pool:
if s["Name"].lower() in rateup_names:
if s["Id"] in rateup_ids:
weights.append(3.0)
else:
weights.append(1.0)
@@ -151,7 +289,10 @@ class StudentDB:
def random_by_rarity(self, rarity: int) -> dict:
"""Pick any student of given rarity from the full pullable pool (fallback)."""
pool = self.by_rarity.get(rarity, [])
return random.choice(pool) if pool else random.choice(self.students)
if pool:
return random.choice(pool)
pullable = [s for s in self.students if s.get("IsLimited", 0) < 2]
return random.choice(pullable or self.students)
def cdn_icon(self, student: dict) -> str:
"""joexyz CDN icon URL for a student (face/upper-body)."""
@@ -160,7 +301,10 @@ class StudentDB:
def cdn_skill_portrait(self, student: dict) -> str:
"""joexyz CDN skill-portrait URL (full-body fallback)."""
return f"{JOEXYZ_CDN}/skill-portraits/Skill_Portrait_{student['DevName']}.png"
dev = student.get("DevName", "")
if not dev:
return ""
return f"{JOEXYZ_CDN}/skill-portraits/Skill_Portrait_{dev}.png"
# Global singleton
+92 -21
View File
@@ -1,9 +1,11 @@
#this is the sqlite database layer for gacha inventory, spark, and eligma##
"""SQLite database for Blue Archive gacha persistence."""
"""SQLite database for Blue Archive gacha persistence — async wrapper layer."""
######################################################################
import asyncio
import sqlite3
import logging
from pathlib import Path
from typing import Any
from .constants import ELIGMA_YIELD
@@ -14,16 +16,16 @@ DB_PATH = Path("data/ba_gacha.db")
def _connect() -> sqlite3.Connection:
"""Open a connection with WAL mode for concurrent reads."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db() -> None:
"""Create tables if they don't exist."""
def _init_db_sync() -> None:
"""Create tables if they don't exist (runs in thread)."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with _connect() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript("""
CREATE TABLE IF NOT EXISTS inventory (
user_id INTEGER NOT NULL,
@@ -47,10 +49,15 @@ def init_db() -> None:
log.info("Database initialised at %s", DB_PATH)
async def init_db() -> None:
"""Async wrapper — initialises the database off the event loop."""
await asyncio.to_thread(_init_db_sync)
# ── Inventory ──────────────────────────────────────────────────────────────
def add_pull(user_id: int, student_id: int, rarity: int) -> int:
"""Record a pull. If student already owned, increment count and return eligma earned."""
def _add_pull_sync(user_id: int, student_id: int, rarity: int) -> int:
"""Record a pull. Returns eligma earned from duplicates."""
with _connect() as conn:
cur = conn.execute(
"SELECT count FROM inventory WHERE user_id = ? AND student_id = ?",
@@ -58,12 +65,12 @@ def add_pull(user_id: int, student_id: int, rarity: int) -> int:
)
row = cur.fetchone()
if row:
# Duplicate — convert to eligma only, keep count at 1
conn.execute("UPDATE inventory SET count = count + 1 WHERE user_id = ? AND student_id = ?",
(user_id, student_id))
eligma = ELIGMA_YIELD.get(rarity, 0)
_add_eligma(conn, user_id, eligma)
_add_eligma_sync(conn, user_id, eligma)
return eligma
else:
# New student
conn.execute(
"INSERT INTO inventory (user_id, student_id, count) VALUES (?, ?, 1)",
(user_id, student_id),
@@ -71,8 +78,11 @@ def add_pull(user_id: int, student_id: int, rarity: int) -> int:
return 0
def get_inventory(user_id: int) -> list[tuple[int, int]]:
"""Return [(student_id, count), ...] for a user."""
async def add_pull(user_id: int, student_id: int, rarity: int) -> int:
return await asyncio.to_thread(_add_pull_sync, user_id, student_id, rarity)
def _get_inventory_sync(user_id: int) -> list[tuple[int, int]]:
with _connect() as conn:
rows = conn.execute(
"SELECT student_id, count FROM inventory WHERE user_id = ? ORDER BY student_id",
@@ -81,8 +91,24 @@ def get_inventory(user_id: int) -> list[tuple[int, int]]:
return rows
def get_inventory_stats(user_id: int) -> dict:
"""Return {unique_total, total_pulls, by_rarity: {1: n, 2: n, 3: n}}."""
async def get_inventory(user_id: int) -> list[tuple[int, int]]:
return await asyncio.to_thread(_get_inventory_sync, user_id)
def _has_student_sync(user_id: int, student_id: int) -> bool:
with _connect() as conn:
row = conn.execute(
"SELECT 1 FROM inventory WHERE user_id = ? AND student_id = ?",
(user_id, student_id),
).fetchone()
return row is not None
async def has_student(user_id: int, student_id: int) -> bool:
return await asyncio.to_thread(_has_student_sync, user_id, student_id)
def _get_inventory_stats_sync(user_id: int) -> dict:
with _connect() as conn:
total_pulls = conn.execute(
"SELECT COALESCE(SUM(count), 0) FROM inventory WHERE user_id = ?",
@@ -95,9 +121,13 @@ def get_inventory_stats(user_id: int) -> dict:
return {"total_pulls": total_pulls, "unique": unique}
async def get_inventory_stats(user_id: int) -> dict:
return await asyncio.to_thread(_get_inventory_stats_sync, user_id)
# ── Spark ───────────────────────────────────────────────────────────────────
def get_spark(user_id: int, banner_id: str) -> int:
def _get_spark_sync(user_id: int, banner_id: str) -> int:
with _connect() as conn:
row = conn.execute(
"SELECT points FROM spark WHERE user_id = ? AND banner_id = ?",
@@ -106,7 +136,11 @@ def get_spark(user_id: int, banner_id: str) -> int:
return row[0] if row else 0
def add_spark(user_id: int, banner_id: str, points: int) -> int:
async def get_spark(user_id: int, banner_id: str) -> int:
return await asyncio.to_thread(_get_spark_sync, user_id, banner_id)
def _add_spark_sync(user_id: int, banner_id: str, points: int) -> int:
"""Add points to a banner's spark counter. Returns new total."""
with _connect() as conn:
conn.execute(
@@ -114,7 +148,6 @@ def add_spark(user_id: int, banner_id: str, points: int) -> int:
"ON CONFLICT(user_id, banner_id) DO UPDATE SET points = points + ?",
(user_id, banner_id, points, points),
)
conn.commit()
row = conn.execute(
"SELECT points FROM spark WHERE user_id = ? AND banner_id = ?",
(user_id, banner_id),
@@ -122,7 +155,11 @@ def add_spark(user_id: int, banner_id: str, points: int) -> int:
return row[0] if row else 0
def spend_spark(user_id: int, banner_id: str, cost: int = 200) -> bool:
async def add_spark(user_id: int, banner_id: str, points: int) -> int:
return await asyncio.to_thread(_add_spark_sync, user_id, banner_id, points)
def _spend_spark_sync(user_id: int, banner_id: str, cost: int = 200) -> bool:
"""Deduct spark points. Returns True if successful (had enough points)."""
with _connect() as conn:
row = conn.execute(
@@ -138,9 +175,39 @@ def spend_spark(user_id: int, banner_id: str, cost: int = 200) -> bool:
return True
async def spend_spark(user_id: int, banner_id: str, cost: int = 200) -> bool:
return await asyncio.to_thread(_spend_spark_sync, user_id, banner_id, cost)
def _spark_claim_sync(user_id: int, banner_id: str, student_id: int, rarity: int, cost: int = 200) -> tuple[bool, str]:
"""Atomically claim a spark student. Returns (success, message)."""
with _connect() as conn:
row = conn.execute(
"SELECT points FROM spark WHERE user_id = ? AND banner_id = ?",
(user_id, banner_id),
).fetchone()
if not row or row[0] < cost:
return False, "Not enough Recruitment Points."
owned = conn.execute(
"SELECT 1 FROM inventory WHERE user_id = ? AND student_id = ?",
(user_id, student_id),
).fetchone()
if owned:
return False, "already_owned"
conn.execute("INSERT INTO inventory (user_id, student_id, count) VALUES (?, ?, 1)",
(user_id, student_id))
conn.execute("UPDATE spark SET points = points - ? WHERE user_id = ? AND banner_id = ?",
(cost, user_id, banner_id))
return True, ""
async def spark_claim(user_id: int, banner_id: str, student_id: int, rarity: int, cost: int = 200) -> tuple[bool, str]:
return await asyncio.to_thread(_spark_claim_sync, user_id, banner_id, student_id, rarity, cost)
# ── Eligma ──────────────────────────────────────────────────────────────────
def _add_eligma(conn: sqlite3.Connection, user_id: int, amount: int) -> None:
def _add_eligma_sync(conn: sqlite3.Connection, user_id: int, amount: int) -> None:
conn.execute(
"INSERT INTO eligma (user_id, amount) VALUES (?, ?) "
"ON CONFLICT(user_id) DO UPDATE SET amount = amount + ?",
@@ -148,9 +215,13 @@ def _add_eligma(conn: sqlite3.Connection, user_id: int, amount: int) -> None:
)
def get_eligma(user_id: int) -> int:
def _get_eligma_sync(user_id: int) -> int:
with _connect() as conn:
row = conn.execute(
"SELECT amount FROM eligma WHERE user_id = ?", (user_id,)
).fetchone()
return row[0] if row else 0
async def get_eligma(user_id: int) -> int:
return await asyncio.to_thread(_get_eligma_sync, user_id)
+87 -44
View File
@@ -22,31 +22,33 @@ from .gacha_renderer import render_pull
from .constants import SPARK_TARGET, BANNER_FILE, DEFAULT_RATES, PULL10_RATES, GACHA_ANIM_PATH, GACHA_ANIM_DURATION
log = logging.getLogger(__name__)
_banner_lock = asyncio.Lock()
def _load_banner_state() -> dict:
async def _load_banner_state() -> dict:
"""Which banner each user has picked."""
if BANNER_FILE.exists():
with open(BANNER_FILE, "r") as f:
return json.load(f)
return {}
async with _banner_lock:
if BANNER_FILE.exists():
with open(BANNER_FILE, "r") as f:
return json.load(f)
return {}
def _save_banner_state(state: dict) -> None:
async def _save_banner_state(state: dict) -> None:
BANNER_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(BANNER_FILE, "w") as f:
async with _banner_lock, open(BANNER_FILE, "w") as f:
json.dump(state, f, indent=2)
def _get_banner_id(user_id: int) -> Optional[str]:
state = _load_banner_state()
async def _get_banner_id(user_id: int) -> Optional[str]:
state = await _load_banner_state()
return state.get(str(user_id))
def _set_banner_id(user_id: int, banner_id: Optional[str]) -> None:
state = _load_banner_state()
async def _set_banner_id(user_id: int, banner_id: Optional[str]) -> None:
state = await _load_banner_state()
state[str(user_id)] = banner_id
_save_banner_state(state)
await _save_banner_state(state)
class BlueArchiveGacha(commands.Cog):
@@ -62,20 +64,21 @@ class BlueArchiveGacha(commands.Cog):
async def cog_load(self) -> None:
"""Load student DB, init SQLite, and fetch banners on startup."""
db.load()
gacha_db.init_db()
await gacha_db.init_db()
await self._refresh_banners()
await db.merge_ennead(self._banner_cache)
self._refresh_task = self.bot.loop.create_task(self._periodic_refresh())
async def cog_unload(self) -> None:
self._refresh_task.cancel()
if hasattr(self, '_refresh_task'):
self._refresh_task.cancel()
async def _refresh_banners(self) -> None:
try:
self._banner_cache = await fetch_banners()
self._cache_ready.set()
except Exception:
log.exception("Banner refresh failed")
finally:
self._cache_ready.set() # always unblock waiters, even on failure
async def _periodic_refresh(self) -> None:
while True:
@@ -171,6 +174,7 @@ class BlueArchiveGacha(commands.Cog):
)
await ctx.reply(embed=embed)
@commands.cooldown(1, 3, commands.BucketType.user)
@gacha.command(name="pick")
async def gacha_pick(self, ctx: commands.Context, *, choice: str = "") -> None:
"""Select your active recruitment banner.
@@ -183,7 +187,7 @@ class BlueArchiveGacha(commands.Cog):
return
if choice == "regular":
_set_banner_id(ctx.author.id, "regular")
await _set_banner_id(ctx.author.id, "regular")
await ctx.reply(
"Active banner set to **Regular Recruitment** (permanent pool, no rate-up).\n"
"Use `!pull` to recruit!"
@@ -211,10 +215,10 @@ class BlueArchiveGacha(commands.Cog):
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
gtype = banner.get("gachaType", "PickupGacha")
_set_banner_id(ctx.author.id, str(banner["id"]))
await _set_banner_id(ctx.author.id, str(banner["id"]))
# Show current spark for this banner
spark = gacha_db.get_spark(ctx.author.id, str(banner["id"]))
spark = await gacha_db.get_spark(ctx.author.id, str(banner["id"]))
await ctx.reply(
f"Active banner: **{gtype}**\n"
@@ -222,6 +226,7 @@ class BlueArchiveGacha(commands.Cog):
f"Recruitment Points: {spark}/{SPARK_TARGET}. Use `!pull` to recruit!"
)
@commands.cooldown(1, 6, commands.BucketType.user)
@commands.command(name="pull")
async def pull_gacha(self, ctx: commands.Context, *, mode: str = "10") -> None:
"""Pull on your active recruitment banner.
@@ -236,7 +241,7 @@ class BlueArchiveGacha(commands.Cog):
count = 10
user_id = ctx.author.id
banner_id = _get_banner_id(user_id)
banner_id = await _get_banner_id(user_id)
# Determine banner info
try:
@@ -276,12 +281,15 @@ class BlueArchiveGacha(commands.Cog):
rateup_names = []
# Perform pulls
pulls = []
pull_results = []
for i in range(count):
# 10th pull guarantee
r = rates
if count == 10 and i == 9:
r = PULL10_RATES
# Guaranteed 2★+ — use banner's 3★ rate, rest to 2★
r3 = rates[0]
r = (r3, 1.0 - r3, 0.0)
else:
r = rates
rarity = roll_rarity(r)
rarity_pool = pool.get(rarity, [])
@@ -290,24 +298,31 @@ class BlueArchiveGacha(commands.Cog):
else:
student = db.random_by_rarity(rarity) # fallback
pulls.append(student)
pull_results.append(student)
# Save pulls to inventory (eligma for dupes) and update spark
user_id = ctx.author.id
spark_banner = str(banner_id) if banner_id else "regular"
spark = gacha_db.add_spark(user_id, spark_banner, count)
# Save pulls to inventory (eligma for dupes) and update spark — all async
spark_banner = str(banner_id) if (banner_id and banner_id != "regular") else None
spark = 0
if spark_banner:
spark = await gacha_db.add_spark(user_id, spark_banner, count)
eligma_earned = 0
for p in pulls:
e = gacha_db.add_pull(user_id, p["Id"], p["StarGrade"])
eligma_earned += e
for p in pull_results:
try:
e = await gacha_db.add_pull(ctx.author.id, p["Id"], p["StarGrade"])
eligma_earned += e
except Exception:
log.exception(
"DB add_pull failed for user %d student %d",
ctx.author.id, p["Id"],
)
dupe_msg = f" | +{eligma_earned} Eligma from dupes" if eligma_earned else ""
# Render result image
try:
async with ctx.typing():
img_bytes = await render_pull(pulls, banner_name, spark)
img_bytes = await render_pull(pull_results, banner_name, spark)
except Exception as e:
log.exception("Render failed")
try:
@@ -339,6 +354,7 @@ class BlueArchiveGacha(commands.Cog):
log.exception("Failed to send result image")
await ctx.reply(f"Failed to send result: {e}")
@commands.cooldown(1, 10, commands.BucketType.user)
@commands.command(name="spark")
async def spark(self, ctx: commands.Context, *, character_name: str = "") -> None:
"""Claim a rate-up student using 200 Recruitment Points from your active banner.
@@ -350,8 +366,13 @@ class BlueArchiveGacha(commands.Cog):
return
user_id = ctx.author.id
banner_id = str(_get_banner_id(user_id) or "regular")
spark = gacha_db.get_spark(user_id, banner_id)
banner_id = str(await _get_banner_id(user_id) or "regular")
spark = await gacha_db.get_spark(user_id, banner_id)
# Regular recruitment has no spark system
if banner_id == "regular":
await ctx.reply("Regular Recruitment does not have a spark system. Pick a banner with `!gacha pick <n>` first.")
return
if spark < SPARK_TARGET:
await ctx.reply(
@@ -360,18 +381,40 @@ class BlueArchiveGacha(commands.Cog):
)
return
# Check if character exists
# Resolve character name
student = db.get_by_name(character_name)
if not student:
await ctx.reply(f"Character **{character_name}** not found in the database.")
return
# Deduct spark and add student to inventory
if not gacha_db.spend_spark(user_id, banner_id):
await ctx.reply("Not enough Recruitment Points.")
# Validate against active banner rate-ups
banner = None
for b in self._all_banners():
if str(b.get("id")) == banner_id:
banner = b
break
if banner:
rateups = [n.lower() for n in banner.get("rateups", [])]
matched = any(
db.get_by_name(rn) is student for rn in rateups
)
if not matched:
rateup_str = ", ".join(banner.get("rateups", []))
await ctx.reply(
f"**{student['Name']}** is not a rate-up on this banner.\n"
f"Rate-ups: {rateup_str}"
)
return
# Atomic claim: check points, check ownership, add student, deduct points
success, msg = await gacha_db.spark_claim(user_id, banner_id, student["Id"], student["StarGrade"])
if not success:
if msg == "already_owned":
await ctx.reply(f"You already own **{student['Name']}** — duplicates come from pulls.")
else:
await ctx.reply(msg)
return
gacha_db.add_pull(user_id, student["Id"], student["StarGrade"])
remaining = gacha_db.get_spark(user_id, banner_id)
remaining = await gacha_db.get_spark(user_id, banner_id)
await ctx.reply(
f"**Spark!** You claimed **{student['Name']}** "
@@ -383,8 +426,8 @@ class BlueArchiveGacha(commands.Cog):
async def gacha_info(self, ctx: commands.Context) -> None:
"""Show your active banner, spark count, and collection summary."""
user_id = ctx.author.id
banner_id = str(_get_banner_id(user_id) or "regular")
spark = gacha_db.get_spark(user_id, banner_id)
banner_id = str(await _get_banner_id(user_id) or "regular")
spark = await gacha_db.get_spark(user_id, banner_id)
# Banner name
banner_name = "Regular Recruitment"
@@ -404,9 +447,9 @@ class BlueArchiveGacha(commands.Cog):
name="Recruitment Points", value=f"{spark}/{SPARK_TARGET}", inline=True
)
embed.add_field(
name="Eligma", value=str(gacha_db.get_eligma(user_id)), inline=True
name="Eligma", value=str(await gacha_db.get_eligma(user_id)), inline=True
)
stats = gacha_db.get_inventory_stats(user_id)
stats = await gacha_db.get_inventory_stats(user_id)
embed.add_field(
name="Collection",
value=f"{stats['unique']} unique students from {stats['total_pulls']} total pulls",
+22 -22
View File
@@ -9,7 +9,7 @@ from discord.ext import commands
from . import db as gacha_db
from .data import db
from .constants import (
PAGE_SIZE, POOL_TOTALS, FILTER_LABELS,
PAGE_SIZE, FILTER_LABELS,
RARITY_SYMBOL, RARITY_LABEL, RARITY_EMBED_COLOR,
ELIGMA_YIELD,
)
@@ -44,9 +44,9 @@ class InventoryView(discord.ui.View):
# ── helpers ──────────────────────────────────────────────────────────
def _get_rows(self) -> list[tuple[int, str, int, int]]:
async def _get_rows(self) -> list[tuple[int, str, int, int]]:
"""Return [(student_id, name, count, rarity), ...] filtered and sorted."""
rows = gacha_db.get_inventory(self.uid)
rows = await gacha_db.get_inventory(self.uid)
result = []
for sid, count in rows:
s = db.get(sid)
@@ -58,13 +58,13 @@ class InventoryView(discord.ui.View):
result.sort(key=lambda x: (-x[3], x[1]))
return result
def _build_dashboard(self) -> discord.Embed:
async def _build_dashboard(self) -> discord.Embed:
"""Landing page with stats and progress bars."""
stats = gacha_db.get_inventory_stats(self.uid)
eligma = gacha_db.get_eligma(self.uid)
stats = await gacha_db.get_inventory_stats(self.uid)
eligma = await gacha_db.get_eligma(self.uid)
# Count by rarity
rows = gacha_db.get_inventory(self.uid)
rows = await gacha_db.get_inventory(self.uid)
by_rarity = {1: 0, 2: 0, 3: 0}
for sid, _ in rows:
s = db.get(sid)
@@ -79,9 +79,9 @@ class InventoryView(discord.ui.View):
# Progress bars per rarity
for r in (3, 2, 1):
pct = _bar(by_rarity[r], POOL_TOTALS[r])
pct = _bar(by_rarity[r], db.pool_totals[r])
embed.add_field(
name=f"{RARITY_LABEL[r]} {by_rarity[r]}/{POOL_TOTALS[r]}",
name=f"{RARITY_LABEL[r]} {by_rarity[r]}/{db.pool_totals[r]}",
value=f"`{pct}`",
inline=True,
)
@@ -95,9 +95,9 @@ class InventoryView(discord.ui.View):
embed.set_footer(text="Use buttons below to browse")
return embed
def _build_collection_page(self) -> discord.Embed:
async def _build_collection_page(self) -> discord.Embed:
"""Paginated student list."""
all_rows = self._get_rows()
all_rows = await self._get_rows()
total_pages = max(1, (len(all_rows) + PAGE_SIZE - 1) // PAGE_SIZE)
self.page = min(self.page, total_pages - 1)
@@ -124,8 +124,8 @@ class InventoryView(discord.ui.View):
embed.set_footer(text=f"Page {self.page + 1}/{total_pages}")
return embed
def _build_eligma_page(self) -> discord.Embed:
amount = gacha_db.get_eligma(self.uid)
async def _build_eligma_page(self) -> discord.Embed:
amount = await gacha_db.get_eligma(self.uid)
return discord.Embed(
title="Eligma",
description=(
@@ -179,35 +179,35 @@ class InventoryView(discord.ui.View):
async def _on_dash(self, interaction: discord.Interaction) -> None:
self.page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self._build_dashboard(), view=self)
await interaction.response.edit_message(embed=await self._build_dashboard(), view=self)
async def _on_coll(self, interaction: discord.Interaction) -> None:
self.page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self._build_collection_page(), view=self)
await interaction.response.edit_message(embed=await self._build_collection_page(), view=self)
async def _on_elig(self, interaction: discord.Interaction) -> None:
self._update_buttons()
await interaction.response.edit_message(embed=self._build_eligma_page(), view=self)
await interaction.response.edit_message(embed=await self._build_eligma_page(), view=self)
async def _on_filter(self, interaction: discord.Interaction) -> None:
label = interaction.data["custom_id"][2:] # strip "f_"
self.filter_rarity = FILTER_LABELS.get(label)
self.page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self._build_collection_page(), view=self)
await interaction.response.edit_message(embed=await self._build_collection_page(), view=self)
async def _on_prev(self, interaction: discord.Interaction) -> None:
self.page = max(0, self.page - 1)
self._update_buttons()
await interaction.response.edit_message(embed=self._build_collection_page(), view=self)
await interaction.response.edit_message(embed=await self._build_collection_page(), view=self)
async def _on_next(self, interaction: discord.Interaction) -> None:
all_rows = self._get_rows()
all_rows = await self._get_rows()
max_page = max(0, (len(all_rows) - 1) // PAGE_SIZE)
self.page = min(max_page, self.page + 1)
self._update_buttons()
await interaction.response.edit_message(embed=self._build_collection_page(), view=self)
await interaction.response.edit_message(embed=await self._build_collection_page(), view=self)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
if interaction.user.id != self.owner_id:
@@ -240,7 +240,7 @@ class Inventory(commands.Cog):
else:
uid = ctx.author.id
rows = gacha_db.get_inventory(uid)
rows = await gacha_db.get_inventory(uid)
if not rows:
if uid == ctx.author.id:
await ctx.reply("Your collection is empty. Use `!pull` to recruit students!")
@@ -249,7 +249,7 @@ class Inventory(commands.Cog):
return
view = InventoryView(uid, ctx.author.id)
await ctx.reply(embed=view._build_dashboard(), view=view)
await ctx.reply(embed=await view._build_dashboard(), view=view)
async def setup(bot: commands.Bot) -> None:
+11 -125
View File
@@ -1,133 +1,19 @@
import os
import asyncio
import tempfile
import discord
# will fix soon tm
"""FFmpeg media conversion cog — temporarily disabled.
Original commands: !togif, !caption, !reverse, !speed, !tomp4, !tomp3, !toopus.
Re-enabled once drawtext injection and resource-exhaustion concerns are addressed.
"""
from discord.ext import commands
class FfmpegThings(commands.Cog):
"""FFmpeg media utilities (disabled)."""
def __init__(self, bot):
self.bot = bot
#this command lists all the ffmpeg commands, not an actualy ffmpeg command##
@commands.command()
async def ffmpeg(self, ctx):
ffmpeg_commands = [f'!{cmd.name}' for cmd in self.get_commands()]
await ctx.reply('\n'.join(ffmpeg_commands))
######################################################################
async def _download_attachment(self, attachment):
suffix = os.path.splitext(attachment.filename)[1] or '.dat'
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
await attachment.save(tmp.name)
return tmp.name, attachment.filename
async def _run_ffmpeg(self, args):
proc = await asyncio.create_subprocess_exec(
'ffmpeg',
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
return proc.returncode, stderr.decode()
async def _process_and_send(self, ctx, ffmpeg_args, temp_in, temp_out):
ret, err = await self._run_ffmpeg(ffmpeg_args)
if ret != 0:
await ctx.reply(f'ffmpeg failed:\n```\n{err}\n```')
if os.path.exists(temp_in):
os.remove(temp_in)
return
if os.path.getsize(temp_out) > 8 * 1024 * 1024:
await ctx.reply('resulting gif is larger than 8mb.')
os.remove(temp_in)
os.remove(temp_out)
return
await ctx.send(file=discord.File(temp_out))
os.remove(temp_in)
os.remove(temp_out)
@commands.command()
async def togif(self, ctx):
if not ctx.message.attachments:
return await ctx.reply('you need to attach a video file.')
temp_in, _ = await self._download_attachment(ctx.message.attachments[0])
temp_out = temp_in + '.gif'
ffmpeg_args = [
'-y',
'-i', temp_in,
'-filter_complex',
'fps=15,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
'-loop', '0',
temp_out,
]
await self._process_and_send(ctx, ffmpeg_args, temp_in, temp_out)
@commands.command()
async def caption(self, ctx, *, text: str):
if not ctx.message.attachments:
return await ctx.reply('attach an image or gif to caption.')
temp_in, _ = await self._download_attachment(ctx.message.attachments[0])
temp_out = temp_in + '_cap.gif'
drawtext = (
f"drawtext=font=Impact:text='{text}':fontcolor=white:fontsize=48:"
"box=1:[email protected]:boxborderw=5:x=(w-text_w)/2:y=10"
)
ffmpeg_args = [
'-y',
'-i', temp_in,
'-filter_complex',
f'{drawtext},fps=15,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
'-loop', '0',
temp_out,
]
await self._process_and_send(ctx, ffmpeg_args, temp_in, temp_out)
@commands.command()
async def reverse(self, ctx):
if not ctx.message.attachments:
return await ctx.reply('attach a gif or video to reverse.')
temp_in, _ = await self._download_attachment(ctx.message.attachments[0])
temp_out = temp_in + '_rev.gif'
ffmpeg_args = [
'-y',
'-i', temp_in,
'-filter_complex',
'reverse,fps=15,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
'-loop', '0',
temp_out,
]
await self._process_and_send(ctx, ffmpeg_args, temp_in, temp_out)
@commands.command()
async def speed(self, ctx, multiplier: float):
if not ctx.message.attachments:
return await ctx.reply('attach a gif or video to adjust speed.')
if multiplier <= 0:
return await ctx.reply('multiplier must be positive.')
temp_in, _ = await self._download_attachment(ctx.message.attachments[0])
temp_out = temp_in + '_spd.gif'
ffmpeg_args = [
'-y',
'-i', temp_in,
'-filter_complex',
f'setpts=PTS*{1/ multiplier},fps=15,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
'-loop', '0',
temp_out,
]
await self._process_and_send(ctx, ffmpeg_args, temp_in, temp_out)
async def setup(bot):
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(FfmpegThings(bot))
+1 -2
View File
@@ -63,13 +63,12 @@ async def main():
await bot.load_extension('cogs.sysinfo')
await bot.load_extension('music.music_wrapper')
await bot.load_extension('cogs.statchannel')
await bot.load_extension('cogs.ffmpeg_things')
# await bot.load_extension('cogs.ffmpeg_things') # will fix soon tm
await bot.load_extension('cogs.roles')
await bot.load_extension('cogs.gitpull')
await bot.load_extension('cogs.currency_converter')
await bot.load_extension('ai.ai_roleplay')
# await bot.load_extension('twitterbridge.leaks_cmd')
await bot.load_extension("rio.ssh_connection_rio")
await bot.load_extension('blue_archive.gacha')
await bot.load_extension('blue_archive.inventory')
await bot.load_extension('firebase_website')
-34
View File
@@ -1,34 +0,0 @@
import os
import subprocess
import discord
from discord.ext import commands
class Rio(commands.Cog):
def __init__(self, bot):
self.bot = bot
@commands.command()
async def connectrio(self, ctx):
target = os.getenv("RIO_CONNECTION")
try:
result = subprocess.run(
["ssh", "-o", "BatchMode=yes", target, "echo ok"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0 and "ok" in result.stdout:
await ctx.reply("ok")
else:
await ctx.reply("failed")
except Exception as e:
await ctx.reply(f"failed: {e}")
async def setup(bot):
await bot.add_cog(Rio(bot))
#the basics are done, i will complete this function once i switch to arch