found a new portrait site
This commit is contained in:
1 parent
9a907df72e
commit
04df9e8f62
4 files changed
+68
-96
No files matched your search
+38
-19
@@ -11,8 +11,7 @@ BG_PATH = ASSETS_DIR / "gacha_bg.png"
|
|||||||
|
|
||||||
# ── APIs ────────────────────────────────────────────────────────────────────
|
# ── APIs ────────────────────────────────────────────────────────────────────
|
||||||
BANNER_API = "https://api.ennead.cc/buruaka/banner"
|
BANNER_API = "https://api.ennead.cc/buruaka/banner"
|
||||||
SCHALE_PORTRAIT = "https://raw.githubusercontent.com/SchaleDB/SchaleDB/main/images/student/portrait"
|
JOEXYZ_CDN = "https://ba.joexyz.online/cdn/v2/images"
|
||||||
WIKI_CDN = "https://static.wikitide.net/bluearchivewiki"
|
|
||||||
|
|
||||||
# ── Gacha rates (matching real Blue Archive) ────────────────────────────────
|
# ── Gacha rates (matching real Blue Archive) ────────────────────────────────
|
||||||
# Regular/Pickup: 3★ = 3.0%, 2★ = 18.5%, 1★ = 78.5%
|
# Regular/Pickup: 3★ = 3.0%, 2★ = 18.5%, 1★ = 78.5%
|
||||||
@@ -69,25 +68,45 @@ PAGE_SIZE = 10
|
|||||||
POOL_TOTALS = {3: 106, 2: 22, 1: 11} # standard-pool size for progress bars
|
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}
|
||||||
|
|
||||||
# ── Wiki name overrides (SchaleDB name → wiki Portrait_ filename stem) ──────
|
# ── CDN icon slug mapping (SchaleDB variant suffix → joexyz CDN slug suffix) ─
|
||||||
WIKI_NAME_MAP: dict[str, str] = {
|
# Base names without a parenthesised variant use the lowercased name directly.
|
||||||
"Aris": "Arisu",
|
# "Aris" is a special case — the CDN uses "aris", not "arisu".
|
||||||
"Shiroko (Cycling)": "Shiroko_(Riding)",
|
CDN_SUFFIX_MAP: dict[str, str] = {
|
||||||
"Shun (Small)": "Shun_(Kid)",
|
"New Year": "newyear",
|
||||||
"Neru (Bunny)": "Neru_(Bunny_Girl)",
|
"Hot Spring": "onsen",
|
||||||
"Karin (Bunny)": "Karin_(Bunny_Girl)",
|
"Bunny": "bunnygirl",
|
||||||
"Asuna (Bunny)": "Asuna_(Bunny_Girl)",
|
"Cheer Squad": "cheerleader",
|
||||||
"Utaha (Cheer Squad)": "Utaha_(Cheerleader)",
|
"Camp": "camp",
|
||||||
"Hibiki (Cheer Squad)": "Hibiki_(Cheerleader)",
|
"Cycling": "cycling",
|
||||||
"Akane (Bunny)": "Akane_(Bunny_Girl)",
|
"Dress": "dress",
|
||||||
"Aris (Maid)": "Arisu_(Maid)",
|
"Small": "small",
|
||||||
"Toki (Bunny)": "Toki_(Bunny_Girl)",
|
"Maid": "maid",
|
||||||
"Kotori (Cheer Squad)": "Kotori_(Cheerleader)",
|
"Track": "track",
|
||||||
"Kotama (Camp)": "Kotama_(Camping)",
|
"Swimsuit": "swimsuit",
|
||||||
"Hare (Camp)": "Hare_(Camping)",
|
"Band": "band",
|
||||||
"Shiroko Terror": "Shiroko_(Terror)",
|
"Christmas": "christmas",
|
||||||
|
"Casual": "casual",
|
||||||
|
"Guide": "guide",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def cdn_icon_slug(name: str) -> str:
|
||||||
|
"""Convert a SchaleDB student Name to the joexyz CDN icon filename slug."""
|
||||||
|
# Normalise "Name Terror" into the same parenthesised path
|
||||||
|
if name.endswith(" Terror"):
|
||||||
|
name = name[:-7] + " (Terror)"
|
||||||
|
# "Aris" is "aris" on the CDN, not "arisu"
|
||||||
|
if name == "Aris":
|
||||||
|
return "aris"
|
||||||
|
|
||||||
|
if "(" in name:
|
||||||
|
base, rest = name.split("(", 1)
|
||||||
|
base = base.strip().lower().replace(" ", "_")
|
||||||
|
variant = rest.rstrip(")").strip()
|
||||||
|
suffix = CDN_SUFFIX_MAP.get(variant, variant.lower().replace(" ", "_"))
|
||||||
|
return f"{base}_{suffix}"
|
||||||
|
|
||||||
|
return name.lower().replace(" ", "_")
|
||||||
|
|
||||||
# ── Font paths ──────────────────────────────────────────────────────────────
|
# ── Font paths ──────────────────────────────────────────────────────────────
|
||||||
FONT_PATHS = [
|
FONT_PATHS = [
|
||||||
"C:/Windows/Fonts/segoeui.ttf",
|
"C:/Windows/Fonts/segoeui.ttf",
|
||||||
|
|||||||
+9
-32
@@ -1,11 +1,9 @@
|
|||||||
#this module loads student data and fetches live banner info from the api##
|
#this module loads student data and fetches live banner info from the api##
|
||||||
"""Student and banner data layer for the Blue Archive gacha simulator."""
|
"""Student and banner data layer for the Blue Archive gacha simulator."""
|
||||||
######################################################################
|
######################################################################
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import urllib.parse
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -15,9 +13,8 @@ import aiohttp
|
|||||||
from .constants import (
|
from .constants import (
|
||||||
BUNDLED_DB,
|
BUNDLED_DB,
|
||||||
BANNER_API,
|
BANNER_API,
|
||||||
SCHALE_PORTRAIT,
|
JOEXYZ_CDN,
|
||||||
WIKI_CDN,
|
cdn_icon_slug,
|
||||||
WIKI_NAME_MAP,
|
|
||||||
BANNER_RATES,
|
BANNER_RATES,
|
||||||
DEFAULT_RATES,
|
DEFAULT_RATES,
|
||||||
PULL10_RATES,
|
PULL10_RATES,
|
||||||
@@ -26,27 +23,6 @@ from .constants import (
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
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:
|
class StudentDB:
|
||||||
"""In-memory student database loaded from the bundled SchaleDB JSON."""
|
"""In-memory student database loaded from the bundled SchaleDB JSON."""
|
||||||
|
|
||||||
@@ -177,13 +153,14 @@ class StudentDB:
|
|||||||
pool = self.by_rarity.get(rarity, [])
|
pool = self.by_rarity.get(rarity, [])
|
||||||
return random.choice(pool) if pool else random.choice(self.students)
|
return random.choice(pool) if pool else random.choice(self.students)
|
||||||
|
|
||||||
def wiki_portrait(self, student: dict) -> str:
|
def cdn_icon(self, student: dict) -> str:
|
||||||
"""Wiki face-portrait CDN URL for a student dict (252x204)."""
|
"""joexyz CDN icon URL for a student (face/upper-body)."""
|
||||||
return wiki_portrait_url(student)
|
slug = cdn_icon_slug(student["Name"])
|
||||||
|
return f"{JOEXYZ_CDN}/students/icons/{slug}.png"
|
||||||
|
|
||||||
def schale_portrait(self, student_id: int) -> str:
|
def cdn_skill_portrait(self, student: dict) -> str:
|
||||||
"""SchaleDB full-body sprite URL (fallback)."""
|
"""joexyz CDN skill-portrait URL (full-body fallback)."""
|
||||||
return schale_portrait_url(student_id)
|
return f"{JOEXYZ_CDN}/skill-portraits/Skill_Portrait_{student['DevName']}.png"
|
||||||
|
|
||||||
|
|
||||||
# Global singleton
|
# Global singleton
|
||||||
|
|||||||
@@ -45,56 +45,42 @@ FOOTER_FONT = _get_font(18, bold=False)
|
|||||||
# ── Image fetching ─────────────────────────────────────────────────────────
|
# ── Image fetching ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]:
|
async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]:
|
||||||
"""Download a student's face portrait.
|
"""Download a student's face icon from the joexyz CDN.
|
||||||
|
|
||||||
Tries the wiki CDN first (252x204 face image). Falls back to SchaleDB
|
Uses the student icon CDN (face/upper-body crop). Falls back to the
|
||||||
full-body sprite with an upper-body crop on failure.
|
skill-portrait (full-body art) if the icon is unavailable.
|
||||||
"""
|
"""
|
||||||
student_id = student["Id"]
|
# 1. Try the face icon
|
||||||
|
icon_url = db.cdn_icon(student)
|
||||||
# 1. Try wiki face portrait
|
|
||||||
wiki_url = db.wiki_portrait(student)
|
|
||||||
try:
|
try:
|
||||||
async with session.get(wiki_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
async with session.get(icon_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
return Image.open(io.BytesIO(data)).convert("RGBA")
|
return Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("Wiki portrait failed for %s, trying SchaleDB fallback", student["Name"])
|
log.debug("CDN icon failed for %s, trying skill portrait fallback", student["Name"])
|
||||||
|
|
||||||
# 2. Fallback: SchaleDB full-body sprite with upper-body crop
|
# 2. Fallback: skill portrait (full-body character art)
|
||||||
schale_url = db.schale_portrait(student_id)
|
sp_url = db.cdn_skill_portrait(student)
|
||||||
try:
|
try:
|
||||||
async with session.get(schale_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
async with session.get(sp_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
img = Image.open(io.BytesIO(data)).convert("RGBA")
|
img = Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
# Crop top portion for face/upper-body (source is ~614x1024)
|
# Skill portraits are full-body; crop upper portion for card fit
|
||||||
p_w, p_h = img.size
|
p_w, p_h = img.size
|
||||||
crop_h = int(p_w * 1.20)
|
crop_h = int(p_w * 1.20)
|
||||||
if crop_h < p_h:
|
if crop_h < p_h:
|
||||||
img = img.crop((0, 0, p_w, crop_h))
|
img = img.crop((0, 0, p_w, crop_h))
|
||||||
return img
|
return img
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Failed to fetch portrait for student %d", student_id)
|
log.exception("Failed to fetch any portrait for %s", student["Name"])
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── Drawing helpers ────────────────────────────────────────────────────────
|
# ── Drawing helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _rounded_rect(
|
|
||||||
draw: ImageDraw.Draw,
|
|
||||||
xy: tuple[int, int, int, int],
|
|
||||||
radius: int,
|
|
||||||
fill: Optional[tuple] = None,
|
|
||||||
outline: Optional[tuple] = None,
|
|
||||||
width: int = 3,
|
|
||||||
) -> None:
|
|
||||||
"""Draw a rounded rectangle."""
|
|
||||||
draw.rounded_rectangle(xy, radius=radius, fill=fill, outline=outline, width=width)
|
|
||||||
|
|
||||||
|
|
||||||
def _star_polygon(cx: int, cy: int, outer_r: int, inner_r: int) -> list[tuple[int, int]]:
|
def _star_polygon(cx: int, cy: int, outer_r: int, inner_r: int) -> list[tuple[int, int]]:
|
||||||
"""Generate vertex list for a 5-pointed star centered at (cx, cy).
|
"""Generate vertex list for a 5-pointed star centered at (cx, cy).
|
||||||
|
|
||||||
@@ -132,17 +118,6 @@ def _draw_star_icons(
|
|||||||
draw.polygon(pts, fill=color)
|
draw.polygon(pts, fill=color)
|
||||||
|
|
||||||
|
|
||||||
def _draw_rounded_rect(
|
|
||||||
draw: ImageDraw.Draw,
|
|
||||||
xy: tuple[int, int, int, int],
|
|
||||||
radius: int,
|
|
||||||
fill: Optional[tuple] = None,
|
|
||||||
outline: Optional[tuple] = None,
|
|
||||||
width: int = 3,
|
|
||||||
) -> None:
|
|
||||||
_rounded_rect(draw, xy, radius, fill, outline, width)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Main render ────────────────────────────────────────────────────────────
|
# ── Main render ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def render_pull(
|
async def render_pull(
|
||||||
@@ -257,18 +232,17 @@ def _draw_card(
|
|||||||
bg = RARITY_BG.get(rarity, (20, 20, 20))
|
bg = RARITY_BG.get(rarity, (20, 20, 20))
|
||||||
|
|
||||||
# Card background with rounded corners
|
# Card background with rounded corners
|
||||||
_draw_rounded_rect(draw, (x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, fill=bg + (255,))
|
draw.rounded_rectangle((x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, fill=bg + (255,))
|
||||||
# Card border
|
# Card border
|
||||||
_draw_rounded_rect(
|
draw.rounded_rectangle(
|
||||||
draw, (x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, outline=color, width=2
|
(x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, outline=color, width=2
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Star banner strip at top ────────────────────────────────────────
|
# ── Star banner strip at top ────────────────────────────────────────
|
||||||
banner_top = y + 1
|
banner_top = y + 1
|
||||||
banner_bottom = y + STAR_BANNER_H
|
banner_bottom = y + STAR_BANNER_H
|
||||||
# Semi-transparent dark strip
|
# Semi-transparent dark strip
|
||||||
_draw_rounded_rect(
|
draw.rounded_rectangle(
|
||||||
draw,
|
|
||||||
(x + 3, banner_top, x + CARD_W - 3, banner_bottom),
|
(x + 3, banner_top, x + CARD_W - 3, banner_bottom),
|
||||||
radius=CARD_RADIUS - 2,
|
radius=CARD_RADIUS - 2,
|
||||||
fill=(0, 0, 0, 80),
|
fill=(0, 0, 0, 80),
|
||||||
@@ -299,7 +273,7 @@ def _draw_card(
|
|||||||
poy = py + (ph - new_h) // 2
|
poy = py + (ph - new_h) // 2
|
||||||
canvas.paste(portrait, (pox, poy), portrait if portrait.mode == "RGBA" else None)
|
canvas.paste(portrait, (pox, poy), portrait if portrait.mode == "RGBA" else None)
|
||||||
else:
|
else:
|
||||||
_draw_rounded_rect(draw, (px, py, px + pw, py + ph), radius=6, fill=(30, 30, 40, 180))
|
draw.rounded_rectangle((px, py, px + pw, py + ph), radius=6, fill=(30, 30, 40, 180))
|
||||||
ph_font = _get_font(16)
|
ph_font = _get_font(16)
|
||||||
msg = "No Image"
|
msg = "No Image"
|
||||||
mw = draw.textlength(msg, font=ph_font)
|
mw = draw.textlength(msg, font=ph_font)
|
||||||
@@ -307,8 +281,7 @@ def _draw_card(
|
|||||||
|
|
||||||
# ── Name strip at bottom ────────────────────────────────────────────
|
# ── Name strip at bottom ────────────────────────────────────────────
|
||||||
name_y = y + CARD_H - NAME_STRIP_H + 2
|
name_y = y + CARD_H - NAME_STRIP_H + 2
|
||||||
_draw_rounded_rect(
|
draw.rounded_rectangle(
|
||||||
draw,
|
|
||||||
(x + 3, name_y, x + CARD_W - 3, y + CARD_H - 3),
|
(x + 3, name_y, x + CARD_W - 3, y + CARD_H - 3),
|
||||||
radius=CARD_RADIUS - 3,
|
radius=CARD_RADIUS - 3,
|
||||||
fill=(0, 0, 0, 150),
|
fill=(0, 0, 0, 150),
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class SysInfo(commands.Cog):
|
|||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
@commands.command()
|
@commands.command()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch(self, ctx):
|
async def fetch(self, ctx):
|
||||||
result = subprocess.run(['fastfetch', '--pipe', '--logo', 'none'], capture_output=True, text=True)
|
result = subprocess.run(['fastfetch', '--pipe', '--logo', 'none'], capture_output=True, text=True)
|
||||||
clean = self.strip_ansi(result.stdout)
|
clean = self.strip_ansi(result.stdout)
|
||||||
|
|||||||
Reference in new issue
Block a user