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
+361 -254

No files matched your search

+2
View File
@@ -19,7 +19,9 @@ _get_id.py
AGENTS.md AGENTS.md
firebase-service-account.json firebase-service-account.json
firebase_service_account.json firebase_service_account.json
firebase_service_account_test.json
**/.codewhale/* **/.codewhale/*
!**/.codewhale/constitution.json !**/.codewhale/constitution.json
.deepseek/ .deepseek/
radio_check.sh 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 ──────────────────────────────────────────────────────────────────── # ── APIs ────────────────────────────────────────────────────────────────────
BANNER_API = "https://api.ennead.cc/buruaka/banner" 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" JOEXYZ_CDN = "https://ba.joexyz.online/cdn/v2/images"
# ── Gacha rates (matching real Blue Archive) ──────────────────────────────── # ── Gacha rates (matching real Blue Archive) ────────────────────────────────
@@ -67,7 +68,6 @@ PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4
# ── Inventory display ─────────────────────────────────────────────────────── # ── Inventory display ───────────────────────────────────────────────────────
PAGE_SIZE = 10 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} 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) ─ # ── 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(" ", "_") return name.lower().replace(" ", "_")
# ── Font paths ────────────────────────────────────────────────────────────── # ── Font paths (tried in order; Pillow default used as last resort) ─────────
FONT_PATHS = [ 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.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
] ]
+150 -6
View File
@@ -13,6 +13,7 @@ import aiohttp
from .constants import ( from .constants import (
BUNDLED_DB, BUNDLED_DB,
BANNER_API, BANNER_API,
CHARACTER_API,
JOEXYZ_CDN, JOEXYZ_CDN,
cdn_icon_slug, cdn_icon_slug,
BANNER_RATES, BANNER_RATES,
@@ -79,11 +80,140 @@ class StudentDB:
excluded, 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]: def get(self, student_id: int) -> Optional[dict]:
return self.by_id.get(student_id) return self.by_id.get(student_id)
def get_by_name(self, name: str) -> Optional[dict]: 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]]: def build_pool(self, banner: Optional[dict]) -> dict[int, list[dict]]:
"""Build the pullable character pool for a specific banner. """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). For 1-2★: uniform random (rate-ups don't affect lower rarities).
""" """
if not pool: 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: 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 = [] weights = []
for s in pool: for s in pool:
if s["Name"].lower() in rateup_names: if s["Id"] in rateup_ids:
weights.append(3.0) weights.append(3.0)
else: else:
weights.append(1.0) weights.append(1.0)
@@ -151,7 +289,10 @@ class StudentDB:
def random_by_rarity(self, rarity: int) -> dict: def random_by_rarity(self, rarity: int) -> dict:
"""Pick any student of given rarity from the full pullable pool (fallback).""" """Pick any student of given rarity from the full pullable pool (fallback)."""
pool = self.by_rarity.get(rarity, []) 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: def cdn_icon(self, student: dict) -> str:
"""joexyz CDN icon URL for a student (face/upper-body).""" """joexyz CDN icon URL for a student (face/upper-body)."""
@@ -160,7 +301,10 @@ class StudentDB:
def cdn_skill_portrait(self, student: dict) -> str: def cdn_skill_portrait(self, student: dict) -> str:
"""joexyz CDN skill-portrait URL (full-body fallback).""" """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 # Global singleton
+92 -21
View File
@@ -1,9 +1,11 @@
#this is the sqlite database layer for gacha inventory, spark, and eligma## #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 sqlite3
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any
from .constants import ELIGMA_YIELD from .constants import ELIGMA_YIELD
@@ -14,16 +16,16 @@ DB_PATH = Path("data/ba_gacha.db")
def _connect() -> sqlite3.Connection: def _connect() -> sqlite3.Connection:
"""Open a connection with WAL mode for concurrent reads.""" """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 = sqlite3.connect(str(DB_PATH))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn return conn
def init_db() -> None: def _init_db_sync() -> None:
"""Create tables if they don't exist.""" """Create tables if they don't exist (runs in thread)."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with _connect() as conn: with _connect() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS inventory ( CREATE TABLE IF NOT EXISTS inventory (
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -47,10 +49,15 @@ def init_db() -> None:
log.info("Database initialised at %s", DB_PATH) 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 ────────────────────────────────────────────────────────────── # ── Inventory ──────────────────────────────────────────────────────────────
def add_pull(user_id: int, student_id: int, rarity: int) -> int: def _add_pull_sync(user_id: int, student_id: int, rarity: int) -> int:
"""Record a pull. If student already owned, increment count and return eligma earned.""" """Record a pull. Returns eligma earned from duplicates."""
with _connect() as conn: with _connect() as conn:
cur = conn.execute( cur = conn.execute(
"SELECT count FROM inventory WHERE user_id = ? AND student_id = ?", "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() row = cur.fetchone()
if row: 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) eligma = ELIGMA_YIELD.get(rarity, 0)
_add_eligma(conn, user_id, eligma) _add_eligma_sync(conn, user_id, eligma)
return eligma return eligma
else: else:
# New student
conn.execute( conn.execute(
"INSERT INTO inventory (user_id, student_id, count) VALUES (?, ?, 1)", "INSERT INTO inventory (user_id, student_id, count) VALUES (?, ?, 1)",
(user_id, student_id), (user_id, student_id),
@@ -71,8 +78,11 @@ def add_pull(user_id: int, student_id: int, rarity: int) -> int:
return 0 return 0
def get_inventory(user_id: int) -> list[tuple[int, int]]: async def add_pull(user_id: int, student_id: int, rarity: int) -> int:
"""Return [(student_id, count), ...] for a user.""" 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: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT student_id, count FROM inventory WHERE user_id = ? ORDER BY student_id", "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 return rows
def get_inventory_stats(user_id: int) -> dict: async def get_inventory(user_id: int) -> list[tuple[int, int]]:
"""Return {unique_total, total_pulls, by_rarity: {1: n, 2: n, 3: n}}.""" 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: with _connect() as conn:
total_pulls = conn.execute( total_pulls = conn.execute(
"SELECT COALESCE(SUM(count), 0) FROM inventory WHERE user_id = ?", "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} 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 ─────────────────────────────────────────────────────────────────── # ── 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: with _connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT points FROM spark WHERE user_id = ? AND banner_id = ?", "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 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.""" """Add points to a banner's spark counter. Returns new total."""
with _connect() as conn: with _connect() as conn:
conn.execute( 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 + ?", "ON CONFLICT(user_id, banner_id) DO UPDATE SET points = points + ?",
(user_id, banner_id, points, points), (user_id, banner_id, points, points),
) )
conn.commit()
row = conn.execute( row = conn.execute(
"SELECT points FROM spark WHERE user_id = ? AND banner_id = ?", "SELECT points FROM spark WHERE user_id = ? AND banner_id = ?",
(user_id, 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 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).""" """Deduct spark points. Returns True if successful (had enough points)."""
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
@@ -138,9 +175,39 @@ def spend_spark(user_id: int, banner_id: str, cost: int = 200) -> bool:
return True 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 ────────────────────────────────────────────────────────────────── # ── 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( conn.execute(
"INSERT INTO eligma (user_id, amount) VALUES (?, ?) " "INSERT INTO eligma (user_id, amount) VALUES (?, ?) "
"ON CONFLICT(user_id) DO UPDATE SET amount = amount + ?", "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: with _connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT amount FROM eligma WHERE user_id = ?", (user_id,) "SELECT amount FROM eligma WHERE user_id = ?", (user_id,)
).fetchone() ).fetchone()
return row[0] if row else 0 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)
+81 -38
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 from .constants import SPARK_TARGET, BANNER_FILE, DEFAULT_RATES, PULL10_RATES, GACHA_ANIM_PATH, GACHA_ANIM_DURATION
log = logging.getLogger(__name__) 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.""" """Which banner each user has picked."""
async with _banner_lock:
if BANNER_FILE.exists(): if BANNER_FILE.exists():
with open(BANNER_FILE, "r") as f: with open(BANNER_FILE, "r") as f:
return json.load(f) return json.load(f)
return {} 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) 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) json.dump(state, f, indent=2)
def _get_banner_id(user_id: int) -> Optional[str]: async def _get_banner_id(user_id: int) -> Optional[str]:
state = _load_banner_state() state = await _load_banner_state()
return state.get(str(user_id)) return state.get(str(user_id))
def _set_banner_id(user_id: int, banner_id: Optional[str]) -> None: async def _set_banner_id(user_id: int, banner_id: Optional[str]) -> None:
state = _load_banner_state() state = await _load_banner_state()
state[str(user_id)] = banner_id state[str(user_id)] = banner_id
_save_banner_state(state) await _save_banner_state(state)
class BlueArchiveGacha(commands.Cog): class BlueArchiveGacha(commands.Cog):
@@ -62,20 +64,21 @@ class BlueArchiveGacha(commands.Cog):
async def cog_load(self) -> None: async def cog_load(self) -> None:
"""Load student DB, init SQLite, and fetch banners on startup.""" """Load student DB, init SQLite, and fetch banners on startup."""
db.load() db.load()
gacha_db.init_db() await gacha_db.init_db()
await self._refresh_banners() await self._refresh_banners()
await db.merge_ennead(self._banner_cache)
self._refresh_task = self.bot.loop.create_task(self._periodic_refresh()) self._refresh_task = self.bot.loop.create_task(self._periodic_refresh())
async def cog_unload(self) -> None: async def cog_unload(self) -> None:
if hasattr(self, '_refresh_task'):
self._refresh_task.cancel() self._refresh_task.cancel()
async def _refresh_banners(self) -> None: async def _refresh_banners(self) -> None:
try: try:
self._banner_cache = await fetch_banners() self._banner_cache = await fetch_banners()
self._cache_ready.set()
except Exception: except Exception:
log.exception("Banner refresh failed") log.exception("Banner refresh failed")
finally:
self._cache_ready.set() # always unblock waiters, even on failure
async def _periodic_refresh(self) -> None: async def _periodic_refresh(self) -> None:
while True: while True:
@@ -171,6 +174,7 @@ class BlueArchiveGacha(commands.Cog):
) )
await ctx.reply(embed=embed) await ctx.reply(embed=embed)
@commands.cooldown(1, 3, commands.BucketType.user)
@gacha.command(name="pick") @gacha.command(name="pick")
async def gacha_pick(self, ctx: commands.Context, *, choice: str = "") -> None: async def gacha_pick(self, ctx: commands.Context, *, choice: str = "") -> None:
"""Select your active recruitment banner. """Select your active recruitment banner.
@@ -183,7 +187,7 @@ class BlueArchiveGacha(commands.Cog):
return return
if choice == "regular": if choice == "regular":
_set_banner_id(ctx.author.id, "regular") await _set_banner_id(ctx.author.id, "regular")
await ctx.reply( await ctx.reply(
"Active banner set to **Regular Recruitment** (permanent pool, no rate-up).\n" "Active banner set to **Regular Recruitment** (permanent pool, no rate-up).\n"
"Use `!pull` to recruit!" "Use `!pull` to recruit!"
@@ -211,10 +215,10 @@ class BlueArchiveGacha(commands.Cog):
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool" rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
gtype = banner.get("gachaType", "PickupGacha") 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 # 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( await ctx.reply(
f"Active banner: **{gtype}**\n" f"Active banner: **{gtype}**\n"
@@ -222,6 +226,7 @@ class BlueArchiveGacha(commands.Cog):
f"Recruitment Points: {spark}/{SPARK_TARGET}. Use `!pull` to recruit!" f"Recruitment Points: {spark}/{SPARK_TARGET}. Use `!pull` to recruit!"
) )
@commands.cooldown(1, 6, commands.BucketType.user)
@commands.command(name="pull") @commands.command(name="pull")
async def pull_gacha(self, ctx: commands.Context, *, mode: str = "10") -> None: async def pull_gacha(self, ctx: commands.Context, *, mode: str = "10") -> None:
"""Pull on your active recruitment banner. """Pull on your active recruitment banner.
@@ -236,7 +241,7 @@ class BlueArchiveGacha(commands.Cog):
count = 10 count = 10
user_id = ctx.author.id user_id = ctx.author.id
banner_id = _get_banner_id(user_id) banner_id = await _get_banner_id(user_id)
# Determine banner info # Determine banner info
try: try:
@@ -276,12 +281,15 @@ class BlueArchiveGacha(commands.Cog):
rateup_names = [] rateup_names = []
# Perform pulls # Perform pulls
pulls = [] pull_results = []
for i in range(count): for i in range(count):
# 10th pull guarantee # 10th pull guarantee
r = rates
if count == 10 and i == 9: 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 = roll_rarity(r)
rarity_pool = pool.get(rarity, []) rarity_pool = pool.get(rarity, [])
@@ -290,24 +298,31 @@ class BlueArchiveGacha(commands.Cog):
else: else:
student = db.random_by_rarity(rarity) # fallback student = db.random_by_rarity(rarity) # fallback
pulls.append(student) pull_results.append(student)
# Save pulls to inventory (eligma for dupes) and update spark # Save pulls to inventory (eligma for dupes) and update spark — all async
user_id = ctx.author.id spark_banner = str(banner_id) if (banner_id and banner_id != "regular") else None
spark_banner = str(banner_id) if banner_id else "regular" spark = 0
spark = gacha_db.add_spark(user_id, spark_banner, count) if spark_banner:
spark = await gacha_db.add_spark(user_id, spark_banner, count)
eligma_earned = 0 eligma_earned = 0
for p in pulls: for p in pull_results:
e = gacha_db.add_pull(user_id, p["Id"], p["StarGrade"]) try:
e = await gacha_db.add_pull(ctx.author.id, p["Id"], p["StarGrade"])
eligma_earned += e 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 "" dupe_msg = f" | +{eligma_earned} Eligma from dupes" if eligma_earned else ""
# Render result image # Render result image
try: try:
async with ctx.typing(): 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: except Exception as e:
log.exception("Render failed") log.exception("Render failed")
try: try:
@@ -339,6 +354,7 @@ class BlueArchiveGacha(commands.Cog):
log.exception("Failed to send result image") log.exception("Failed to send result image")
await ctx.reply(f"Failed to send result: {e}") await ctx.reply(f"Failed to send result: {e}")
@commands.cooldown(1, 10, commands.BucketType.user)
@commands.command(name="spark") @commands.command(name="spark")
async def spark(self, ctx: commands.Context, *, character_name: str = "") -> None: async def spark(self, ctx: commands.Context, *, character_name: str = "") -> None:
"""Claim a rate-up student using 200 Recruitment Points from your active banner. """Claim a rate-up student using 200 Recruitment Points from your active banner.
@@ -350,8 +366,13 @@ class BlueArchiveGacha(commands.Cog):
return return
user_id = ctx.author.id user_id = ctx.author.id
banner_id = str(_get_banner_id(user_id) or "regular") banner_id = str(await _get_banner_id(user_id) or "regular")
spark = gacha_db.get_spark(user_id, banner_id) 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: if spark < SPARK_TARGET:
await ctx.reply( await ctx.reply(
@@ -360,18 +381,40 @@ class BlueArchiveGacha(commands.Cog):
) )
return return
# Check if character exists # Resolve character name
student = db.get_by_name(character_name) student = db.get_by_name(character_name)
if not student: if not student:
await ctx.reply(f"Character **{character_name}** not found in the database.") await ctx.reply(f"Character **{character_name}** not found in the database.")
return return
# Deduct spark and add student to inventory # Validate against active banner rate-ups
if not gacha_db.spend_spark(user_id, banner_id): banner = None
await ctx.reply("Not enough Recruitment Points.") 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 return
gacha_db.add_pull(user_id, student["Id"], student["StarGrade"])
remaining = gacha_db.get_spark(user_id, banner_id) # 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
remaining = await gacha_db.get_spark(user_id, banner_id)
await ctx.reply( await ctx.reply(
f"**Spark!** You claimed **{student['Name']}** " f"**Spark!** You claimed **{student['Name']}** "
@@ -383,8 +426,8 @@ class BlueArchiveGacha(commands.Cog):
async def gacha_info(self, ctx: commands.Context) -> None: async def gacha_info(self, ctx: commands.Context) -> None:
"""Show your active banner, spark count, and collection summary.""" """Show your active banner, spark count, and collection summary."""
user_id = ctx.author.id user_id = ctx.author.id
banner_id = str(_get_banner_id(user_id) or "regular") banner_id = str(await _get_banner_id(user_id) or "regular")
spark = gacha_db.get_spark(user_id, banner_id) spark = await gacha_db.get_spark(user_id, banner_id)
# Banner name # Banner name
banner_name = "Regular Recruitment" banner_name = "Regular Recruitment"
@@ -404,9 +447,9 @@ class BlueArchiveGacha(commands.Cog):
name="Recruitment Points", value=f"{spark}/{SPARK_TARGET}", inline=True name="Recruitment Points", value=f"{spark}/{SPARK_TARGET}", inline=True
) )
embed.add_field( 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( embed.add_field(
name="Collection", name="Collection",
value=f"{stats['unique']} unique students from {stats['total_pulls']} total pulls", 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 . import db as gacha_db
from .data import db from .data import db
from .constants import ( from .constants import (
PAGE_SIZE, POOL_TOTALS, FILTER_LABELS, PAGE_SIZE, FILTER_LABELS,
RARITY_SYMBOL, RARITY_LABEL, RARITY_EMBED_COLOR, RARITY_SYMBOL, RARITY_LABEL, RARITY_EMBED_COLOR,
ELIGMA_YIELD, ELIGMA_YIELD,
) )
@@ -44,9 +44,9 @@ class InventoryView(discord.ui.View):
# ── helpers ────────────────────────────────────────────────────────── # ── 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.""" """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 = [] result = []
for sid, count in rows: for sid, count in rows:
s = db.get(sid) s = db.get(sid)
@@ -58,13 +58,13 @@ class InventoryView(discord.ui.View):
result.sort(key=lambda x: (-x[3], x[1])) result.sort(key=lambda x: (-x[3], x[1]))
return result return result
def _build_dashboard(self) -> discord.Embed: async def _build_dashboard(self) -> discord.Embed:
"""Landing page with stats and progress bars.""" """Landing page with stats and progress bars."""
stats = gacha_db.get_inventory_stats(self.uid) stats = await gacha_db.get_inventory_stats(self.uid)
eligma = gacha_db.get_eligma(self.uid) eligma = await gacha_db.get_eligma(self.uid)
# Count by rarity # 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} by_rarity = {1: 0, 2: 0, 3: 0}
for sid, _ in rows: for sid, _ in rows:
s = db.get(sid) s = db.get(sid)
@@ -79,9 +79,9 @@ class InventoryView(discord.ui.View):
# Progress bars per rarity # Progress bars per rarity
for r in (3, 2, 1): 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( 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}`", value=f"`{pct}`",
inline=True, inline=True,
) )
@@ -95,9 +95,9 @@ class InventoryView(discord.ui.View):
embed.set_footer(text="Use buttons below to browse") embed.set_footer(text="Use buttons below to browse")
return embed return embed
def _build_collection_page(self) -> discord.Embed: async def _build_collection_page(self) -> discord.Embed:
"""Paginated student list.""" """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) total_pages = max(1, (len(all_rows) + PAGE_SIZE - 1) // PAGE_SIZE)
self.page = min(self.page, total_pages - 1) 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}") embed.set_footer(text=f"Page {self.page + 1}/{total_pages}")
return embed return embed
def _build_eligma_page(self) -> discord.Embed: async def _build_eligma_page(self) -> discord.Embed:
amount = gacha_db.get_eligma(self.uid) amount = await gacha_db.get_eligma(self.uid)
return discord.Embed( return discord.Embed(
title="Eligma", title="Eligma",
description=( description=(
@@ -179,35 +179,35 @@ class InventoryView(discord.ui.View):
async def _on_dash(self, interaction: discord.Interaction) -> None: async def _on_dash(self, interaction: discord.Interaction) -> None:
self.page = 0 self.page = 0
self._update_buttons() 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: async def _on_coll(self, interaction: discord.Interaction) -> None:
self.page = 0 self.page = 0
self._update_buttons() 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: async def _on_elig(self, interaction: discord.Interaction) -> None:
self._update_buttons() 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: async def _on_filter(self, interaction: discord.Interaction) -> None:
label = interaction.data["custom_id"][2:] # strip "f_" label = interaction.data["custom_id"][2:] # strip "f_"
self.filter_rarity = FILTER_LABELS.get(label) self.filter_rarity = FILTER_LABELS.get(label)
self.page = 0 self.page = 0
self._update_buttons() 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: async def _on_prev(self, interaction: discord.Interaction) -> None:
self.page = max(0, self.page - 1) self.page = max(0, self.page - 1)
self._update_buttons() 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: 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) max_page = max(0, (len(all_rows) - 1) // PAGE_SIZE)
self.page = min(max_page, self.page + 1) self.page = min(max_page, self.page + 1)
self._update_buttons() 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: async def interaction_check(self, interaction: discord.Interaction) -> bool:
if interaction.user.id != self.owner_id: if interaction.user.id != self.owner_id:
@@ -240,7 +240,7 @@ class Inventory(commands.Cog):
else: else:
uid = ctx.author.id uid = ctx.author.id
rows = gacha_db.get_inventory(uid) rows = await gacha_db.get_inventory(uid)
if not rows: if not rows:
if uid == ctx.author.id: if uid == ctx.author.id:
await ctx.reply("Your collection is empty. Use `!pull` to recruit students!") await ctx.reply("Your collection is empty. Use `!pull` to recruit students!")
@@ -249,7 +249,7 @@ class Inventory(commands.Cog):
return return
view = InventoryView(uid, ctx.author.id) 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: async def setup(bot: commands.Bot) -> None:
+11 -125
View File
@@ -1,133 +1,19 @@
import os # will fix soon tm
import asyncio """FFmpeg media conversion cog — temporarily disabled.
import tempfile
import discord 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 from discord.ext import commands
class FfmpegThings(commands.Cog): class FfmpegThings(commands.Cog):
"""FFmpeg media utilities (disabled)."""
def __init__(self, bot): def __init__(self, bot):
self.bot = 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): async def setup(bot: commands.Bot) -> None:
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):
await bot.add_cog(FfmpegThings(bot)) 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('cogs.sysinfo')
await bot.load_extension('music.music_wrapper') await bot.load_extension('music.music_wrapper')
await bot.load_extension('cogs.statchannel') 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.roles')
await bot.load_extension('cogs.gitpull') await bot.load_extension('cogs.gitpull')
await bot.load_extension('cogs.currency_converter') await bot.load_extension('cogs.currency_converter')
await bot.load_extension('ai.ai_roleplay') await bot.load_extension('ai.ai_roleplay')
# await bot.load_extension('twitterbridge.leaks_cmd') # 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.gacha')
await bot.load_extension('blue_archive.inventory') await bot.load_extension('blue_archive.inventory')
await bot.load_extension('firebase_website') 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