diff --git a/.gitignore b/.gitignore index ae384ea..92057fe 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/blue_archive/constants.py b/blue_archive/constants.py index fa1d443..c0cc874 100644 --- a/blue_archive/constants.py +++ b/blue_archive/constants.py @@ -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", ] diff --git a/blue_archive/data.py b/blue_archive/data.py index 8716cb0..f0a06c4 100644 --- a/blue_archive/data.py +++ b/blue_archive/data.py @@ -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 diff --git a/blue_archive/db.py b/blue_archive/db.py index ae36ca7..705c69f 100644 --- a/blue_archive/db.py +++ b/blue_archive/db.py @@ -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) diff --git a/blue_archive/gacha.py b/blue_archive/gacha.py index 2adbb0f..cc5256a 100644 --- a/blue_archive/gacha.py +++ b/blue_archive/gacha.py @@ -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 ` 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", diff --git a/blue_archive/inventory.py b/blue_archive/inventory.py index e7c9430..3533f3e 100644 --- a/blue_archive/inventory.py +++ b/blue_archive/inventory.py @@ -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: diff --git a/cogs/ffmpeg_things.py b/cogs/ffmpeg_things.py index feae126..8535748 100644 --- a/cogs/ffmpeg_things.py +++ b/cogs/ffmpeg_things.py @@ -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:boxcolor=black@0.6: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)) diff --git a/main.py b/main.py index 56d93bb..98ecf8d 100644 --- a/main.py +++ b/main.py @@ -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') diff --git a/rio/ssh_connection_rio.py b/rio/ssh_connection_rio.py deleted file mode 100644 index ba926c4..0000000 --- a/rio/ssh_connection_rio.py +++ /dev/null @@ -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 \ No newline at end of file