rollback checkpoint: hardened gacha, disabled ffmpeg, deleted rio, async db
This commit is contained in:
1 parent
05c0599d1c
commit
30efb9105f
9 files changed
+361
-254
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
|
||||||
Reference in new issue
Block a user