Compare commits

..
3 Commits
Author SHA1 Message Date
esef 460f4bf78c fix the fucknig timezone 2026-10-29 00:49:49 +03:00
esef ff579807f1 fuck you php 2026-10-21 22:59:39 +03:00
esefandClaude Opus 5 810a9a6b12 blue_archive: refresh bundled roster (264 students)
Weekly discordbot-refresh timer output, committed from the server.
Adds Nagusa (Swimsuit); nothing removed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-20 13:16:57 +03:00
6 changed files with 113 additions and 23 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
[{"Id": 10000, "IsReleased": [true, true, true], "DefaultOrder": 0, "PathName": "Aru", "DevName": "Aru", "Name": "Aru", "School": "Gehenna", "Club": "Kohshinjo68", "StarGrade": 3, "SquadType": "Main", "TacticRole": "DamageDealer", "Summons": [], "Position": "Back", "BulletType": "Explosion", "ArmorType": "LightArmor", "StreetBattleAdaptation": 4, "OutdoorBattleAdaptation": 2, "IndoorBattleAdaptation": 0, "WeaponType": "SR", "WeaponImg": "weapon_icon_10000", "Cover": true, "Equipment": ["Hat", "Hairpin", "Watch"], "CollectionBG": "BG_Gehenna_Collection", "FamilyName": "Rikuhachima", "PersonalName": "Aru", "SchoolYear": "2nd Year", "CharacterAge": "16 years old", "Birthday": "March 12th", "CharacterSSRNew": "Heh heh. Good choice.", "ProfileIntroduction": "The self-proclaimed boss of Problem Solver 68.\n\nAru expanded Gehenna Academy's Problem Solver 68 club without permission to conduct illegal business activities. She aspires to be seen as a dashing and debonair villain, but her frequent blunders always expose her as a poser.", "Hobby": "Studying management", "CharacterVoice": "Reina Kondō", "BirthDay": "3/12", "Illustrator": "DoReMi", "Designer": "DoReMi", "CharHeightMetric": "160cm", "CharHeightImperial": "5'2\"", "StabilityPoint": 1988, "AttackPower1": 369, "AttackPower100": 3690, "MaxHP1": 2236, "MaxHP100": 19390, "DefensePower1": 19, "DefensePower100": 119, "HealPower1": 1408, "HealPower100": 4225, "DodgePoint": 201, "AccuracyPoint": 905, "CriticalPoint": 201, "CriticalDamageRate": 20000, "AmmoCount": 5, "AmmoCost": 1, "Range": 750, "RegenCost": 700, "Skills": [{"SkillType": "autoattack", "Effects": [{"Type": "DMGSingle", "Hits": [10000], "Scale": [10000], "Frames": {"AttackEnterDuration": 45, "AttackStartDuration": 15, "AttackEndDuration": 16, "AttackBurstRoundOverDelay": 50, "AttackIngDuration": 42, "AttackReloadDuration": 70}, "CriticalCheck": "Check"}]}, {"SkillType": "ex", "Name": "Hard-Boiled Shot", "Desc": "Fires a shot that deals <?1> damage to one enemy and explodes for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["274%", "315%", "397%", "438%", "521%"], ["292%", "335%", "423%", "467%", "554%"]], "Cost": [4, 4, 4, 4, 4], "Duration": 132, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 200}], "Icon": "COMMON_SKILLICON_CIRCLE", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [27438, 31554, 39781, 43898, 52125]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [29207, 33584, 42344, 46727, 55487]}]}, {"SkillType": "normal", "Name": "Noir Attack", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 50% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["152%", "160%", "168%", "198%", "206%", "213%", "244%", "251%", "259%", "290%"], ["251%", "263%", "276%", "326%", "338%", "351%", "401%", "414%", "426%", "476%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15270, 16035, 16801, 19851, 20617, 21376, 24433, 25198, 25958, 29014]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25102, 26355, 27608, 32631, 33884, 35137, 40155, 41414, 42667, 47685]}]}, {"SkillType": "gearnormal", "Name": "Noir Attack+", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 100% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["156%", "163%", "171%", "203%", "210%", "218%", "249%", "257%", "265%", "296%"], ["256%", "269%", "282%", "333%", "346%", "359%", "410%", "423%", "436%", "487%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15616, 16398, 17181, 20305, 21082, 21864, 24988, 25771, 26547, 29672]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25669, 26956, 28237, 33368, 34656, 35937, 41068, 42355, 43637, 48768]}]}, {"SkillType": "passive", "Name": "The Gravity of a Boss", "Desc": "Increases <b:CriticalDamage> by <?1>.", "Parameters": [["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Coefficient", "Scale": [1400, 1470, 1540, 1820, 1890, 1960, 2240, 2310, 2380, 2660]}]}, {"SkillType": "weaponpassive", "Name": "The Gravity of a Boss+", "Desc": "Increases <b:CriticalDamage> by <?1> plus a further <?2>.", "Parameters": [["2000", "2100", "2200", "2600", "2700", "2800", "3200", "3300", "3400", "3800"], ["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Base", "Scale": [2000, 2100, 2200, 2600, 2700,Line truncated [{"Id": 10000, "IsReleased": [true, true, true], "DefaultOrder": 0, "PathName": "Aru", "DevName": "Aru", "Name": "Aru", "School": "Gehenna", "Club": "Kohshinjo68", "StarGrade": 3, "SquadType": "Main", "TacticRole": "DamageDealer", "Summons": [], "Position": "Back", "BulletType": "Explosion", "ArmorType": "LightArmor", "StreetBattleAdaptation": 4, "OutdoorBattleAdaptation": 2, "IndoorBattleAdaptation": 0, "WeaponType": "SR", "WeaponImg": "weapon_icon_10000", "Cover": true, "Equipment": ["Hat", "Hairpin", "Watch"], "CollectionBG": "BG_Gehenna_Collection", "FamilyName": "Rikuhachima", "PersonalName": "Aru", "SchoolYear": "2nd Year", "CharacterAge": "16 years old", "Birthday": "March 12th", "CharacterSSRNew": "Heh heh. Good choice.", "ProfileIntroduction": "The self-proclaimed boss of Problem Solver 68.\n\nAru expanded Gehenna Academy's Problem Solver 68 club without permission to conduct illegal business activities. She aspires to be seen as a dashing and debonair villain, but her frequent blunders always expose her as a poser.", "Hobby": "Studying management", "CharacterVoice": "Reina Kondō", "BirthDay": "3/12", "Illustrator": "DoReMi", "Designer": "DoReMi", "CharHeightMetric": "160cm", "CharHeightImperial": "5'2\"", "StabilityPoint": 1988, "AttackPower1": 369, "AttackPower100": 3690, "MaxHP1": 2236, "MaxHP100": 19390, "DefensePower1": 19, "DefensePower100": 119, "HealPower1": 1408, "HealPower100": 4225, "DodgePoint": 201, "AccuracyPoint": 905, "CriticalPoint": 201, "CriticalDamageRate": 20000, "AmmoCount": 5, "AmmoCost": 1, "Range": 750, "RegenCost": 700, "Skills": [{"SkillType": "autoattack", "Effects": [{"Type": "DMGSingle", "Hits": [10000], "Scale": [10000], "Frames": {"AttackEnterDuration": 45, "AttackStartDuration": 15, "AttackEndDuration": 16, "AttackBurstRoundOverDelay": 50, "AttackIngDuration": 42, "AttackReloadDuration": 70}, "CriticalCheck": "Check"}]}, {"SkillType": "ex", "Name": "Hard-Boiled Shot", "Desc": "Fires a shot that deals <?1> damage to one enemy and explodes for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["274%", "315%", "397%", "438%", "521%"], ["292%", "335%", "423%", "467%", "554%"]], "Cost": [4, 4, 4, 4, 4], "Duration": 132, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 200}], "Icon": "COMMON_SKILLICON_CIRCLE", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [27438, 31554, 39781, 43898, 52125]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [29207, 33584, 42344, 46727, 55487]}]}, {"SkillType": "normal", "Name": "Noir Attack", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 50% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["152%", "160%", "168%", "198%", "206%", "213%", "244%", "251%", "259%", "290%"], ["251%", "263%", "276%", "326%", "338%", "351%", "401%", "414%", "426%", "476%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15270, 16035, 16801, 19851, 20617, 21376, 24433, 25198, 25958, 29014]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25102, 26355, 27608, 32631, 33884, 35137, 40155, 41414, 42667, 47685]}]}, {"SkillType": "gearnormal", "Name": "Noir Attack+", "Desc": "Every 25s, fires a shot that deals <?1> damage to one enemy with a 100% chance to explode for an additional <?2> damage to enemies in a circular area around the target.", "Parameters": [["156%", "163%", "171%", "203%", "210%", "218%", "249%", "257%", "265%", "296%"], ["256%", "269%", "282%", "333%", "346%", "359%", "410%", "423%", "436%", "487%"]], "Duration": 100, "Range": 950, "Radius": [{"Type": "Circle", "Radius": 100}], "Icon": "COMMON_SKILLICON_TARGET", "Effects": [{"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [15616, 16398, 17181, 20305, 21082, 21864, 24988, 25771, 26547, 29672]}, {"Type": "DMGSingle", "CriticalCheck": "Check", "Scale": [25669, 26956, 28237, 33368, 34656, 35937, 41068, 42355, 43637, 48768]}]}, {"SkillType": "passive", "Name": "The Gravity of a Boss", "Desc": "Increases <b:CriticalDamage> by <?1>.", "Parameters": [["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Coefficient", "Scale": [1400, 1470, 1540, 1820, 1890, 1960, 2240, 2310, 2380, 2660]}]}, {"SkillType": "weaponpassive", "Name": "The Gravity of a Boss+", "Desc": "Increases <b:CriticalDamage> by <?1> plus a further <?2>.", "Parameters": [["2000", "2100", "2200", "2600", "2700", "2800", "3200", "3300", "3400", "3800"], ["14%", "14.7%", "15.4%", "18.2%", "18.9%", "19.6%", "22.4%", "23.1%", "23.8%", "26.6%"]], "Icon": "COMMON_SKILLICON_WEAPONBUFF", "Effects": [{"Type": "BuffSelf", "Stat": "CriticalDamageRate_Base", "Scale": [2000, 2100, 2200, 2600, 2700,Line truncated
+8
View File
@@ -8,6 +8,7 @@ _PKG_DIR = Path(__file__).parent
_DATA_DIR = _PKG_DIR.parent / "data" _DATA_DIR = _PKG_DIR.parent / "data"
BUNDLED_DB = _PKG_DIR / "ba_students.json" BUNDLED_DB = _PKG_DIR / "ba_students.json"
BANNER_FILE = _DATA_DIR / "ba_banner_state.json" BANNER_FILE = _DATA_DIR / "ba_banner_state.json"
PORTRAIT_CACHE_DIR = _DATA_DIR / "portraits"
ASSETS_DIR = _PKG_DIR / "assets" ASSETS_DIR = _PKG_DIR / "assets"
BG_PATH = ASSETS_DIR / "gacha_bg.png" BG_PATH = ASSETS_DIR / "gacha_bg.png"
GACHA_ANIM_PATH = ASSETS_DIR / "Arona_Blue_Gacha_Animation.gif" GACHA_ANIM_PATH = ASSETS_DIR / "Arona_Blue_Gacha_Animation.gif"
@@ -17,6 +18,9 @@ GACHA_ANIM_DURATION = 3.5 # 195 frames × 100 ms (used to be 19.5 will be chang
BANNER_API = "https://api.ennead.cc/buruaka/banner" BANNER_API = "https://api.ennead.cc/buruaka/banner"
CHARACTER_API = "https://api.ennead.cc/buruaka/character" CHARACTER_API = "https://api.ennead.cc/buruaka/character"
PORTRAIT_CDN = "https://schaledb.com/images" PORTRAIT_CDN = "https://schaledb.com/images"
# Wiki art is the nicest but its host goes down for days at a time. Once a
# student is cached from a lesser source, re-check the wiki this often.
WIKI_RETRY_HOURS = 24
WIKI_PORTRAIT_BASE = "https://bluearchive.wiki/wiki/Special:FilePath/" WIKI_PORTRAIT_BASE = "https://bluearchive.wiki/wiki/Special:FilePath/"
WIKI_VARIANT_MAP: dict[str, str] = { WIKI_VARIANT_MAP: dict[str, str] = {
"Bunny": "Bunny Girl", "Bunny": "Bunny Girl",
@@ -80,6 +84,10 @@ STAR_BANNER_H = 36
NAME_STRIP_H = 42 NAME_STRIP_H = 42
PORTRAIT_TOP = STAR_BANNER_H + 4 PORTRAIT_TOP = STAR_BANNER_H + 4
PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4 PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4
# Fraction of a full-body sprite kept when cropping it to a bust, and the
# fraction of it searched to locate the head horizontally.
BUST_HEIGHT_FRAC = 0.45
BUST_HEAD_FRAC = 0.25
# ── Inventory display ─────────────────────────────────────────────────────── # ── Inventory display ───────────────────────────────────────────────────────
PAGE_SIZE = 10 PAGE_SIZE = 10
+5 -1
View File
@@ -355,8 +355,12 @@ class StudentDB:
name = f"{base.strip()} ({variant})" name = f"{base.strip()} ({variant})"
return WIKI_PORTRAIT_BASE + quote(f"Portrait {name}.png", safe="") return WIKI_PORTRAIT_BASE + quote(f"Portrait {name}.png", safe="")
def cdn_collection(self, student: dict) -> str:
"""SchaleDB CDN collection art URL (in-game card portrait, keyed by ID)."""
return f"{PORTRAIT_CDN}/student/collection/{student['Id']}.webp"
def cdn_sprite(self, student: dict) -> str: def cdn_sprite(self, student: dict) -> str:
"""SchaleDB CDN full-body sprite URL (fallback, keyed by ID).""" """SchaleDB CDN full-body sprite URL (last resort, keyed by ID)."""
return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp" return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp"
+83 -11
View File
@@ -6,6 +6,9 @@ import functools
import io import io
import logging import logging
import math import math
import os
import time
import uuid
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -21,7 +24,9 @@ from .constants import (
HEADER_Y, FOOTER_Y, HEADER_Y, FOOTER_Y,
STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H, STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H,
RARITY_COLOR, RARITY_BG, RARITY_COLOR, RARITY_BG,
BUST_HEIGHT_FRAC, BUST_HEAD_FRAC,
BG_PATH, SPARK_TARGET, BG_PATH, SPARK_TARGET,
PORTRAIT_CACHE_DIR, WIKI_RETRY_HOURS,
FONT_PATHS, FONT_PATHS,
) )
@@ -47,20 +52,87 @@ FOOTER_FONT = _get_font(18, bold=False)
# ── Image fetching ───────────────────────────────────────────────────────── # ── Image fetching ─────────────────────────────────────────────────────────
def _bust_crop(img: Image.Image) -> Image.Image:
"""Crop a full-body sprite to head-and-shoulders so it fills the card.
Anchored on the alpha box top (Kid variants sit lower in the frame) and on
the alpha-weighted centre of the top slice, so thin props like rifles and
parasols barely pull the crop off the face.
"""
alpha = img.getchannel("A")
box = alpha.getbbox()
if not box:
return img
left, top, right, bottom = box
fig_h = bottom - top
crop_h = min(img.height - top, max(1, int(fig_h * BUST_HEIGHT_FRAC)))
head = alpha.crop((left, top, right, top + max(1, int(fig_h * BUST_HEAD_FRAC))))
cols = head.resize((head.width, 1), Image.BOX).tobytes()
weight = sum(cols)
offset = sum(x * c for x, c in enumerate(cols)) // weight if weight else head.width // 2
cx = left + offset
crop_w = min(img.width, max(1, round(crop_h * (CARD_W - 12) / PORTRAIT_H)))
x0 = max(0, min(img.width - crop_w, cx - crop_w // 2))
return img.crop((x0, top, x0 + crop_w, top + crop_h))
def _cached_portrait(student: dict) -> Optional[Image.Image]:
"""Load a cached portrait, or None when one should be (re)fetched."""
try:
wiki = PORTRAIT_CACHE_DIR / f"{student['Id']}.wiki.png"
if wiki.exists():
return Image.open(wiki).convert("RGBA")
alt = PORTRAIT_CACHE_DIR / f"{student['Id']}.alt.png"
if alt.exists() and time.time() - alt.stat().st_mtime < WIKI_RETRY_HOURS * 3600:
return Image.open(alt).convert("RGBA")
except Exception: # unreadable file would otherwise blank the card forever
log.warning("bad cached portrait for %s, refetching", student["Name"])
return None
def _cache_portrait(student: dict, img: Image.Image, from_wiki: bool) -> None:
"""Keep fetched art on disk so an upstream outage can't change the cards."""
try:
PORTRAIT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
kind = "wiki" if from_wiki else "alt"
path = PORTRAIT_CACHE_DIR / f"{student['Id']}.{kind}.png"
tmp = path.parent / f"{path.name}.{uuid.uuid4().hex[:8]}.tmp"
img.save(tmp, format="PNG") # the .tmp name hides the format from Pillow
os.replace(tmp, path) # a restart mid-save must not leave half a card
if from_wiki: # wiki art wins from here on, drop the stand-in
(PORTRAIT_CACHE_DIR / f"{student['Id']}.alt.png").unlink(missing_ok=True)
except Exception:
log.warning("could not cache portrait for %s", student["Name"], exc_info=True)
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 portrait (wiki bust art first, full sprite fallback).""" """A student's portrait: disk cache, else wiki art, collection art, sprite."""
for url in (db.cdn_portrait(student), db.cdn_sprite(student)): cached = await asyncio.to_thread(_cached_portrait, student)
if cached is not None:
return cached
sources = (
(db.cdn_portrait(student), "wiki"),
(db.cdn_collection(student), "collection"),
(db.cdn_sprite(student), "sprite"),
)
for url, kind in sources:
try: try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200: if resp.status != 200:
data = await resp.read() continue
img = Image.open(io.BytesIO(data)).convert("RGBA") data = await resp.read()
# Full-body art is tall; crop the upper portion to fit the card. img = Image.open(io.BytesIO(data)).convert("RGBA")
p_w, p_h = img.size if kind == "sprite":
crop_h = int(p_w * 1.20) img = _bust_crop(img)
if crop_h < p_h: elif kind == "wiki":
img = img.crop((0, 0, p_w, crop_h)) # Wiki art is a face shot; only trim it if it is very tall.
return img p_w, p_h = img.size
crop_h = int(p_w * 1.20)
if crop_h < p_h:
img = img.crop((0, 0, p_w, crop_h))
await asyncio.to_thread(_cache_portrait, student, img, kind == "wiki")
return img
except Exception: except Exception:
log.debug("portrait fetch failed via %s", url) log.debug("portrait fetch failed via %s", url)
+15 -6
View File
@@ -17,7 +17,7 @@ from PIL import Image
from . import db as gacha_db from . import db as gacha_db
from .data import db, fetch_banners, banner_unavailable_reason from .data import db, fetch_banners, banner_unavailable_reason
from .gacha_renderer import render_pull from .gacha_renderer import render_pull
from .constants import CHARACTER_API, BG_PATH, GACHA_ANIM_PATH, FONT_PATHS from .constants import CHARACTER_API, BG_PATH, GACHA_ANIM_PATH, FONT_PATHS, PORTRAIT_CACHE_DIR
OK, WARN, FAIL, INFO = "✅", "⚠️", "❌", "ℹ️" OK, WARN, FAIL, INFO = "✅", "⚠️", "❌", "ℹ️"
REFRESH_INTERVAL = 6 * 3600 # must match BlueArchiveGacha._periodic_refresh REFRESH_INTERVAL = 6 * 3600 # must match BlueArchiveGacha._periodic_refresh
@@ -121,7 +121,7 @@ def _check_rateups(live_banners: list[dict]) -> tuple[list[Row], list[dict]]:
async def _check_portraits(students: list[dict]) -> list[Row]: async def _check_portraits(students: list[dict]) -> list[Row]:
"""Probe wiki art and the sprite fallback separately for rate-up students.""" """Probe each art source separately for rate-up students."""
s = "Portraits" s = "Portraits"
if not students: if not students:
return [(INFO, s, "No live rate-up students to probe")] return [(INFO, s, "No live rate-up students to probe")]
@@ -136,16 +136,25 @@ async def _check_portraits(students: list[dict]) -> list[Row]:
timeout = aiohttp.ClientTimeout(total=30, connect=5) # same as render_pull timeout = aiohttp.ClientTimeout(total=30, connect=5) # same as render_pull
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
results = await asyncio.gather(*( results = await asyncio.gather(*(
asyncio.gather(_probe_image(session, db.cdn_portrait(st)), _probe_image(session, db.cdn_sprite(st))) asyncio.gather(
_probe_image(session, db.cdn_portrait(st)),
_probe_image(session, db.cdn_collection(st)),
_probe_image(session, db.cdn_sprite(st)),
)
for st in sample for st in sample
)) ))
for st, (wiki, sprite) in zip(sample, results): for st, (wiki, collection, sprite) in zip(sample, results):
if wiki is None: if wiki is None:
rows.append((OK, s, f"{st['Name']}: wiki art OK")) rows.append((OK, s, f"{st['Name']}: wiki art OK"))
elif collection is None:
rows.append((WARN, s, f"{st['Name']}: wiki art failed ({wiki}), collection art OK"))
elif sprite is None: elif sprite is None:
rows.append((WARN, s, f"{st['Name']}: wiki art failed ({wiki}), sprite fallback OK")) rows.append((WARN, s, f"{st['Name']}: wiki+collection failed, sprite fallback OK"))
else: else:
rows.append((FAIL, s, f"{st['Name']}: no art (wiki {wiki}, sprite {sprite}) — card renders blank")) rows.append((FAIL, s, f"{st['Name']}: no art (wiki {wiki}, collection {collection}, "
f"sprite {sprite}) — card renders blank"))
cached = len(list(PORTRAIT_CACHE_DIR.glob("*.png"))) if PORTRAIT_CACHE_DIR.exists() else 0
rows.append((INFO, s, f"{cached} portraits cached on disk"))
return rows return rows
+1 -4
View File
@@ -132,10 +132,7 @@ class TimestampModal(discord.ui.Modal, title="Timestamp Converter"):
# --- output single Long Date+Time format --- # --- output single Long Date+Time format ---
lines = [ lines = [
f"**{local_dt.strftime('%d %B at %H:%M')}**", f"<t:{unix_ts}:F>",
f"`<t:{unix_ts}:F>` ← Long Date+Time",
"",
"*Times display in each viewer's local timezone.*",
] ]
await interaction.response.send_message( await interaction.response.send_message(