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