287 lines
10 KiB
Python
287 lines
10 KiB
Python
#this module loads student data and fetches live banner info from the api##
|
||
"""Student and banner data layer for the Blue Archive gacha simulator."""
|
||
######################################################################
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import random
|
||
import urllib.parse
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import aiohttp
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
BUNDLED_DB = Path(__file__).parent / "ba_students.json"
|
||
BANNER_API = "https://api.ennead.cc/buruaka/banner"
|
||
SCHALE_PORTRAIT = "https://raw.githubusercontent.com/SchaleDB/SchaleDB/main/images/student/portrait"
|
||
SCHALE_ICON = "https://raw.githubusercontent.com/SchaleDB/SchaleDB/main/images/student/icon"
|
||
|
||
# Wiki portrait CDN: static.wikitide.net/bluearchivewiki/{md5[0]}/{md5[:2]}/{filename}
|
||
WIKI_CDN = "https://static.wikitide.net/bluearchivewiki"
|
||
|
||
# schaleDB Name → wiki Portrait_ filename (without .png extension)
|
||
# Most work automatically by replacing spaces with underscores; these are the exceptions.
|
||
WIKI_NAME_MAP: dict[str, str] = {
|
||
"Aris": "Arisu",
|
||
"Shiroko (Cycling)": "Shiroko_(Riding)",
|
||
"Shun (Small)": "Shun_(Kid)",
|
||
"Neru (Bunny)": "Neru_(Bunny_Girl)",
|
||
"Karin (Bunny)": "Karin_(Bunny_Girl)",
|
||
"Asuna (Bunny)": "Asuna_(Bunny_Girl)",
|
||
"Utaha (Cheer Squad)": "Utaha_(Cheerleader)",
|
||
"Hibiki (Cheer Squad)": "Hibiki_(Cheerleader)",
|
||
"Akane (Bunny)": "Akane_(Bunny_Girl)",
|
||
"Aris (Maid)": "Arisu_(Maid)",
|
||
"Toki (Bunny)": "Toki_(Bunny_Girl)",
|
||
"Kotori (Cheer Squad)": "Kotori_(Cheerleader)",
|
||
"Kotama (Camp)": "Kotama_(Camping)",
|
||
"Hare (Camp)": "Hare_(Camping)",
|
||
"Shiroko Terror": "Shiroko_(Terror)",
|
||
# Track variants — no wiki portrait; fall back to SchaleDB
|
||
}
|
||
|
||
|
||
def _wiki_filename(student: dict) -> str:
|
||
"""Build the wiki Portrait_ filename (no extension) for a student."""
|
||
name = student["Name"]
|
||
mapped = WIKI_NAME_MAP.get(name)
|
||
if mapped is not None:
|
||
return mapped
|
||
return name.replace(" ", "_")
|
||
|
||
|
||
def wiki_portrait_url(student: dict) -> str:
|
||
"""Compute the CDN URL for a student's wiki portrait (252x204 face image)."""
|
||
filename = f"Portrait_{_wiki_filename(student)}.png"
|
||
h = hashlib.md5(filename.encode()).hexdigest()
|
||
return f"{WIKI_CDN}/{h[0]}/{h[:2]}/{urllib.parse.quote(filename)}"
|
||
|
||
|
||
def schale_portrait_url(student_id: int) -> str:
|
||
"""Full-body sprite URL (fallback if wiki portrait is unavailable)."""
|
||
return f"{SCHALE_PORTRAIT}/{student_id}.webp"
|
||
|
||
|
||
class StudentDB:
|
||
"""In-memory student database loaded from the bundled SchaleDB JSON."""
|
||
|
||
def __init__(self) -> None:
|
||
self.students: list[dict] = []
|
||
self.by_id: dict[int, dict] = {}
|
||
self.by_name: dict[str, dict] = {}
|
||
self.by_rarity: dict[int, list[dict]] = {1: [], 2: [], 3: []}
|
||
self._loaded = False
|
||
|
||
def load(self) -> None:
|
||
if self._loaded:
|
||
return
|
||
with open(BUNDLED_DB, "r", encoding="utf-8") as f:
|
||
self.students = json.load(f)
|
||
|
||
excluded = 0
|
||
for s in self.students:
|
||
sid = s["Id"]
|
||
name = s["Name"].lower()
|
||
rarity = s["StarGrade"]
|
||
limit = s.get("IsLimited") or 0
|
||
|
||
self.by_id[sid] = s
|
||
self.by_name[name] = s
|
||
|
||
# Exclude welfare/event characters (IsLimited >= 2) from pullable pool
|
||
if limit >= 2:
|
||
excluded += 1
|
||
continue
|
||
|
||
if rarity in self.by_rarity:
|
||
self.by_rarity[rarity].append(s)
|
||
|
||
# Pre-build standard pool (IsLimited == 0 only) and full pool
|
||
self._std_pool: dict[int, list[dict]] = {1: [], 2: [], 3: []}
|
||
self._limited_pool: list[dict] = [] # all IsLimited == 1 chars
|
||
for s in self.students:
|
||
limit = s.get("IsLimited") or 0
|
||
if limit >= 2:
|
||
continue
|
||
r = s["StarGrade"]
|
||
if limit == 0 and r in self._std_pool:
|
||
self._std_pool[r].append(s)
|
||
elif limit == 1:
|
||
self._limited_pool.append(s)
|
||
|
||
self._loaded = True
|
||
log.info(
|
||
"Loaded %d students (std: %d, limited: %d, welfare: %d)",
|
||
len(self.students),
|
||
sum(len(v) for v in self._std_pool.values()),
|
||
len(self._limited_pool),
|
||
excluded,
|
||
)
|
||
|
||
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())
|
||
|
||
def build_pool(self, banner: Optional[dict]) -> dict[int, list[dict]]:
|
||
"""Build the effective character pool for a banner.
|
||
|
||
Returns {1: [...], 2: [...], 3: [...]} with characters pullable on this banner.
|
||
|
||
- Regular Recruitment (banner=None): standard pool only
|
||
- PickupGacha / LimitedGacha: standard pool + rate-up students
|
||
- FesGacha: standard pool + all limiteds + rate-ups
|
||
"""
|
||
pool: dict[int, list[dict]] = {
|
||
r: list(self._std_pool[r]) for r in (1, 2, 3)
|
||
}
|
||
|
||
if banner is None:
|
||
return pool
|
||
|
||
gtype = banner.get("gachaType", "")
|
||
rateup_names = [n.lower() for n in banner.get("rateups", [])]
|
||
|
||
# For Fes banners, add ALL past limited characters to the pool
|
||
if gtype == "FesGacha":
|
||
for s in self._limited_pool:
|
||
r = s["StarGrade"]
|
||
if r in pool:
|
||
pool[r].append(s)
|
||
|
||
# Add rate-up students (they might already be in the pool, but we need
|
||
# to identify them for weighted selection later)
|
||
for name in rateup_names:
|
||
s = self.get_by_name(name)
|
||
if s and (s.get("IsLimited") or 0) < 2:
|
||
r = s["StarGrade"]
|
||
if r in pool and s not in pool[r]:
|
||
pool[r].append(s)
|
||
|
||
return pool
|
||
|
||
def weighted_pick(self, pool: list[dict], rateup_names: list[str], rarity: int) -> dict:
|
||
"""Pick a student from a pool with rate-up weighting.
|
||
|
||
For 3★ rarity, rate-up students each get ~23% weight (matching the
|
||
real game's 0.7% per rate-up out of 3% total). For 1-2★, uniform.
|
||
"""
|
||
if not pool:
|
||
return random.choice(self.students)
|
||
|
||
if rarity == 3 and rateup_names:
|
||
# Weighted: rate-up chars get 3x weight each vs normal chars
|
||
weights = []
|
||
for s in pool:
|
||
if s["Name"].lower() in rateup_names:
|
||
weights.append(3.0)
|
||
else:
|
||
weights.append(1.0)
|
||
return random.choices(pool, weights=weights, k=1)[0]
|
||
|
||
return random.choice(pool)
|
||
|
||
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)
|
||
|
||
def wiki_portrait(self, student: dict) -> str:
|
||
"""Wiki face-portrait CDN URL for a student dict (252x204)."""
|
||
return wiki_portrait_url(student)
|
||
|
||
def schale_portrait(self, student_id: int) -> str:
|
||
"""SchaleDB full-body sprite URL (fallback)."""
|
||
return schale_portrait_url(student_id)
|
||
|
||
|
||
# Global singleton
|
||
db = StudentDB()
|
||
|
||
|
||
# --- Banner types & rates ---------------------------------------------------
|
||
|
||
# In-game rates per banner type
|
||
# Format: { gachaType: (rate_3star, rate_2star, rate_1star) }
|
||
# Fes banners double the 3-star rate to 6%
|
||
BANNER_RATES: dict[str, tuple[float, float, float]] = {
|
||
"FesGacha": (0.06, 0.185, 0.755),
|
||
}
|
||
DEFAULT_RATES = (0.03, 0.185, 0.785)
|
||
|
||
# The 10th pull on a 10-pull: guarantee at least 2-star
|
||
# (97% 2-star, 3% 3-star — the 1-star rate is redirected into 2-star)
|
||
PULL10_RATES = (0.03, 0.97, 0.0)
|
||
|
||
|
||
def roll_rarity(rates: tuple[float, float, float]) -> int:
|
||
"""Roll rarity tier given (p_3star, p_2star, p_1star). Returns 3, 2, or 1."""
|
||
r = random.random()
|
||
if r < rates[0]:
|
||
return 3
|
||
elif r < rates[0] + rates[1]:
|
||
return 2
|
||
else:
|
||
return 1
|
||
|
||
|
||
def get_rates_for_banner(banner: dict) -> tuple[float, float, float]:
|
||
"""Return (3star_rate, 2star_rate, 1star_rate) for a banner's gachaType."""
|
||
return BANNER_RATES.get(banner.get("gachaType", ""), DEFAULT_RATES)
|
||
|
||
|
||
# --- Banner fetching ---------------------------------------------------------
|
||
|
||
async def fetch_banners(session: Optional[aiohttp.ClientSession] = None) -> dict:
|
||
"""Fetch live banner data from the BlueArchiveAPI.
|
||
|
||
Returns the parsed JSON: {"current": [...], "upcoming": [...], "ended": [...]}
|
||
"""
|
||
close_session = session is None
|
||
if close_session:
|
||
session = aiohttp.ClientSession()
|
||
|
||
try:
|
||
async with session.get(BANNER_API) as resp:
|
||
if resp.status == 200:
|
||
data = await resp.json()
|
||
log.info("Fetched %d current, %d upcoming banners",
|
||
len(data.get("current", [])),
|
||
len(data.get("upcoming", [])))
|
||
return data
|
||
else:
|
||
log.warning("Banner API returned status %d", resp.status)
|
||
return {"current": [], "upcoming": [], "ended": []}
|
||
except Exception:
|
||
log.exception("Failed to fetch banners from API")
|
||
return {"current": [], "upcoming": [], "ended": []}
|
||
finally:
|
||
if close_session:
|
||
await session.close()
|
||
|
||
|
||
def format_banner_embed(banner: dict, index: int) -> str:
|
||
"""Format a single banner as an embed field value string."""
|
||
gtype = banner.get("gachaType", "PickupGacha")
|
||
rateups = ", ".join(banner.get("rateups", [])) or "Standard pool"
|
||
started = banner.get("startedAt", 0)
|
||
ended = banner.get("endedAt", 0)
|
||
|
||
if started and ended:
|
||
start_dt = datetime.fromtimestamp(started / 1000, tz=timezone.utc)
|
||
end_dt = datetime.fromtimestamp(ended / 1000, tz=timezone.utc)
|
||
dates = f"{start_dt.strftime('%b %d')} – {end_dt.strftime('%b %d, %Y')}"
|
||
else:
|
||
dates = "Permanent"
|
||
|
||
is_fes = " [FES - 6% 3*!]" if gtype == "FesGacha" else ""
|
||
return (
|
||
f"**#{index + 1} — {gtype}{is_fes}**\n"
|
||
f"Rate-up: {rateups}\n"
|
||
f"{dates}"
|
||
)
|