add blue archive gacha simulator cog with live banners and wiki portraits
This commit is contained in:
1 parent
c2133705fe
commit
95228a425d
9 files changed
+1044
-28
No files matched your search
@@ -12,3 +12,5 @@ yuuka-radio-streams.md
|
|||||||
esef_learns.md
|
esef_learns.md
|
||||||
icy_supported.json
|
icy_supported.json
|
||||||
icy_metadata_radios.md
|
icy_metadata_radios.md
|
||||||
|
__pycache__/
|
||||||
|
ssh_update.py
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
yuuka/
|
||||||
|
├── main.py # Bot entry point, cog loader, bridge initialization
|
||||||
|
├── cogs/ # Discord command modules (general, sysinfo, gifs, etc.)
|
||||||
|
├── ai/ # AI roleplay integration (DeepSeek API, per-channel personas)
|
||||||
|
│ └── personalities/ # Character prompt files
|
||||||
|
├── music/ # Music playback subsystem (wavelink + yt-dlp + ffmpeg)
|
||||||
|
│ ├── player/ # Playback commands, events, queue management
|
||||||
|
│ ├── radio/ # Internet radio streaming
|
||||||
|
│ └── shared/ # Constants, shared state, utilities
|
||||||
|
├── tgbridge/ # Telegram ↔ Discord two-way message forwarding
|
||||||
|
├── twitterbridge/ # Twitter/X RSS feed → Discord webhook relay
|
||||||
|
├── rio/ # SSH connection utilities
|
||||||
|
├── data/ # Runtime persistence files (gitignored)
|
||||||
|
├── gifs/ # GIF assets (gitignored)
|
||||||
|
└── credentials_test.env # Environment variable template
|
||||||
|
```
|
||||||
|
|
||||||
|
Each cog is a self-contained `commands.Cog` subclass loaded in `main.py`. Bridge components run as background `asyncio` tasks.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
This is a personal bot with no formal build system. Run it directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- Copy `credentials_test.env` → `credentials.env` and fill in your tokens.
|
||||||
|
- There is no test suite. Test by running the bot locally and using `!`-prefixed commands in a Discord server.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
- **Language**: Python 3.12+
|
||||||
|
- **Indentation**: 4 spaces
|
||||||
|
- **Naming**: `snake_case` for files, variables, and functions; `PascalCase` for classes; `UPPER_CASE` for constants
|
||||||
|
- **Cogs**: One class per file in `cogs/`, subclassing `commands.Cog`, loaded via `await bot.load_extension('cogs.<name>')`
|
||||||
|
- **Music subpackage**: Uses mixin-based composition — `Music` inherits from several command/event mixins
|
||||||
|
- **Async**: All I/O uses `async`/`await`; bridge tasks run with `asyncio.create_task()`
|
||||||
|
- No linter or formatter is currently configured
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
No formal test framework is in place. When adding or changing features, validate manually:
|
||||||
|
|
||||||
|
1. Start the bot locally with a test Discord token.
|
||||||
|
2. Exercise each affected command in a private server.
|
||||||
|
3. For music/radio, test with different stream types (YouTube, direct audio URLs, radio streams).
|
||||||
|
4. For bridges, confirm messages flow correctly in both directions.
|
||||||
|
5. Check the console for unhandled exceptions — the `logging` level is `INFO`.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
This is a personal repository with informal conventions:
|
||||||
|
|
||||||
|
- **Commit messages**: Short, lowercase, imperative mood (e.g., `fix radio race conditions`, `add radio mode`). No strict format is enforced.
|
||||||
|
- **Branches**: Work directly on `master` or use short-lived feature branches.
|
||||||
|
- **PRs**: Not used — the project is single-contributor.
|
||||||
|
- **Before committing**: Ensure the bot starts without import errors and any changed cogs load successfully.
|
||||||
|
|
||||||
|
## Environment & Secrets
|
||||||
|
|
||||||
|
- Store secrets in `credentials.env` (gitignored). The template is `credentials_test.env`.
|
||||||
|
- Required: `DISCORD_TOKEN`, `OWNER_ID`. Optional: `TELEGRAM_BOT_TOKEN`, `TWITTER_RSS_URL`, `DEEPSEEK_API`, and webhook URLs.
|
||||||
|
- The bot is managed as a `systemd` service on Arch Linux and updated via `git pull` over SSH (the `!gitpull` cog handles remote updates).
|
||||||
@@ -29,3 +29,4 @@ run these to see the sub-commands:
|
|||||||
- `!sysinfo` — hardware monitoring
|
- `!sysinfo` — hardware monitoring
|
||||||
- `!gifs` — the gif list
|
- `!gifs` — the gif list
|
||||||
- `!fun` — the junk commands
|
- `!fun` — the junk commands
|
||||||
|
- `!gacha` — blue archive recruitment simulator (pick banners, pull with live rates)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Blue Archive gacha simulator package."""
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
#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"
|
||||||
|
PORTRAIT_BASE = "https://raw.githubusercontent.com/SchaleDB/SchaleDB/main/images/student/portrait"
|
||||||
|
ICON_BASE = "https://raw.githubusercontent.com/SchaleDB/SchaleDB/main/images/student/icon"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
for s in self.students:
|
||||||
|
sid = s["Id"]
|
||||||
|
name = s["Name"].lower()
|
||||||
|
rarity = s["StarGrade"]
|
||||||
|
|
||||||
|
self.by_id[sid] = s
|
||||||
|
self.by_name[name] = s
|
||||||
|
if rarity in self.by_rarity:
|
||||||
|
self.by_rarity[rarity].append(s)
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
log.info(
|
||||||
|
"Loaded %d students (1*: %d, 2*: %d, 3*: %d)",
|
||||||
|
len(self.students),
|
||||||
|
len(self.by_rarity[1]),
|
||||||
|
len(self.by_rarity[2]),
|
||||||
|
len(self.by_rarity[3]),
|
||||||
|
)
|
||||||
|
|
||||||
|
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 random_by_rarity(self, rarity: int) -> dict:
|
||||||
|
"""Pick a random student of the given base rarity (1, 2, or 3)."""
|
||||||
|
pool = self.by_rarity.get(rarity, [])
|
||||||
|
if not pool:
|
||||||
|
# Fallback: any student
|
||||||
|
pool = self.students
|
||||||
|
return random.choice(pool)
|
||||||
|
|
||||||
|
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}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
#this is the gacha cog for blue archive, handles all recruitment commands##
|
||||||
|
"""Blue Archive Gacha Simulator — in-game-accurate recruitment with live banners."""
|
||||||
|
######################################################################
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from .data import (
|
||||||
|
db,
|
||||||
|
fetch_banners,
|
||||||
|
format_banner_embed,
|
||||||
|
roll_rarity,
|
||||||
|
get_rates_for_banner,
|
||||||
|
PULL10_RATES,
|
||||||
|
DEFAULT_RATES,
|
||||||
|
)
|
||||||
|
from .gacha_renderer import render_pull
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATE_FILE = Path("data/ba_gacha_state.json")
|
||||||
|
SPARK_TARGET = 200
|
||||||
|
_state_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state() -> dict:
|
||||||
|
"""Load per-user gacha state from disk."""
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
with open(STATE_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: dict) -> None:
|
||||||
|
"""Persist per-user gacha state to disk."""
|
||||||
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_state(user_id: int) -> dict:
|
||||||
|
async with _state_lock:
|
||||||
|
state = _load_state()
|
||||||
|
key = str(user_id)
|
||||||
|
if key not in state:
|
||||||
|
state[key] = {"banner_id": None, "spark": 0, "history": []}
|
||||||
|
return state[key]
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_user_state(user_id: int, user_state: dict) -> None:
|
||||||
|
async with _state_lock:
|
||||||
|
state = _load_state()
|
||||||
|
state[str(user_id)] = user_state
|
||||||
|
_save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
class BlueArchiveGacha(commands.Cog):
|
||||||
|
"""Blue Archive recruitment simulator with live banners and in-game-style renders."""
|
||||||
|
|
||||||
|
def __init__(self, bot: commands.Bot) -> None:
|
||||||
|
self.bot = bot
|
||||||
|
self._banner_cache: dict = {"current": [], "upcoming": [], "ended": []}
|
||||||
|
self._cache_ready = asyncio.Event()
|
||||||
|
|
||||||
|
# ── Lifecycle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def cog_load(self) -> None:
|
||||||
|
"""Load student DB and fetch banners on startup."""
|
||||||
|
db.load()
|
||||||
|
await self._refresh_banners()
|
||||||
|
# Background refresh every 6 hours
|
||||||
|
self._refresh_task = self.bot.loop.create_task(self._periodic_refresh())
|
||||||
|
|
||||||
|
async def cog_unload(self) -> None:
|
||||||
|
self._refresh_task.cancel()
|
||||||
|
|
||||||
|
async def _refresh_banners(self) -> None:
|
||||||
|
try:
|
||||||
|
self._banner_cache = await fetch_banners()
|
||||||
|
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:
|
||||||
|
await asyncio.sleep(6 * 3600) # 6 hours
|
||||||
|
await self._refresh_banners()
|
||||||
|
|
||||||
|
def _all_banners(self) -> list[dict]:
|
||||||
|
"""Return a flat list of all available (current + upcoming) banners for picking."""
|
||||||
|
return self._banner_cache.get("current", []) + self._banner_cache.get("upcoming", [])
|
||||||
|
|
||||||
|
# ── Commands ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@commands.group(name="gacha", aliases=["banners", "g"], invoke_without_command=True)
|
||||||
|
async def gacha(self, ctx: commands.Context) -> None:
|
||||||
|
"""Show live recruitment banners. Use subcommands: pick, info."""
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._cache_ready.wait(), timeout=15.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass # proceed with whatever we have
|
||||||
|
banners = self._all_banners()
|
||||||
|
|
||||||
|
if not banners:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Recruitment",
|
||||||
|
description="No active banners available right now.\n"
|
||||||
|
"Try the permanent **Regular Recruitment** banner instead:\n"
|
||||||
|
"`!gacha pick regular`",
|
||||||
|
color=0x5BA0D0,
|
||||||
|
)
|
||||||
|
await ctx.reply(embed=embed)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build embed
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Recruitment — Available Banners",
|
||||||
|
description="Use `!gacha pick <number>` to select a banner, then `!pull` to recruit!\n"
|
||||||
|
"Or `!gacha pick regular` for the permanent banner.\n"
|
||||||
|
"`!gacha info` to see your status.",
|
||||||
|
color=0x5BA0D0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, banner in enumerate(banners[:8]): # Discord embed field limit
|
||||||
|
embed.add_field(
|
||||||
|
name="",
|
||||||
|
value=format_banner_embed(banner, i),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
embed.set_footer(text="Banner data from BlueArchiveAPI • Updates every 6 hours")
|
||||||
|
await ctx.reply(embed=embed)
|
||||||
|
|
||||||
|
@gacha.command(name="pick")
|
||||||
|
async def gacha_pick(self, ctx: commands.Context, *, choice: str = "") -> None:
|
||||||
|
"""Select your active recruitment banner.
|
||||||
|
|
||||||
|
Usage: !gacha pick <number> or !gacha pick regular
|
||||||
|
"""
|
||||||
|
choice = choice.strip().lower()
|
||||||
|
if not choice:
|
||||||
|
await ctx.reply("Usage: `!gacha pick <number>` or `!gacha pick regular`")
|
||||||
|
return
|
||||||
|
|
||||||
|
if choice == "regular":
|
||||||
|
user_state = await _get_user_state(ctx.author.id)
|
||||||
|
user_state["banner_id"] = "regular"
|
||||||
|
await _set_user_state(ctx.author.id, user_state)
|
||||||
|
await ctx.reply(
|
||||||
|
"Active banner set to **Regular Recruitment** (permanent pool, no rate-up).\n"
|
||||||
|
"Use `!pull` to recruit!"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try numeric pick from banner list
|
||||||
|
try:
|
||||||
|
idx = int(choice) - 1
|
||||||
|
except ValueError:
|
||||||
|
await ctx.reply("Usage: `!gacha pick <number>` or `!gacha pick regular`")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._cache_ready.wait(), timeout=15.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
banners = self._all_banners()
|
||||||
|
|
||||||
|
if idx < 0 or idx >= len(banners):
|
||||||
|
await ctx.reply(f"Invalid banner number. There are {len(banners)} banners available (1–{len(banners)}).")
|
||||||
|
return
|
||||||
|
|
||||||
|
banner = banners[idx]
|
||||||
|
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
|
||||||
|
gtype = banner.get("gachaType", "PickupGacha")
|
||||||
|
|
||||||
|
user_state = await _get_user_state(ctx.author.id)
|
||||||
|
user_state["banner_id"] = banner["id"]
|
||||||
|
user_state["spark"] = 0 # reset spark on banner switch
|
||||||
|
await _set_user_state(ctx.author.id, user_state)
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
f"Active banner: **{gtype}**\n"
|
||||||
|
f"Rate-up: {rateups}\n"
|
||||||
|
f"Spark count reset to 0. Use `!pull` to recruit!"
|
||||||
|
)
|
||||||
|
|
||||||
|
@commands.command(name="pull")
|
||||||
|
async def pull_gacha(self, ctx: commands.Context, *, mode: str = "10") -> None:
|
||||||
|
"""Perform a gacha pull. Default is 10-pull.
|
||||||
|
|
||||||
|
Usage: !pull (10-pull)
|
||||||
|
!pull single (1-pull)
|
||||||
|
"""
|
||||||
|
mode = mode.strip().lower()
|
||||||
|
if mode in ("single", "1", "one"):
|
||||||
|
count = 1
|
||||||
|
else:
|
||||||
|
count = 10
|
||||||
|
|
||||||
|
user_state = await _get_user_state(ctx.author.id)
|
||||||
|
banner_id = user_state.get("banner_id")
|
||||||
|
|
||||||
|
# Determine banner info
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._cache_ready.wait(), timeout=15.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
banner = None
|
||||||
|
banner_name = "Regular Recruitment"
|
||||||
|
rates = DEFAULT_RATES
|
||||||
|
|
||||||
|
if banner_id and banner_id != "regular":
|
||||||
|
for b in self._all_banners():
|
||||||
|
if b["id"] == banner_id:
|
||||||
|
banner = b
|
||||||
|
break
|
||||||
|
if banner:
|
||||||
|
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
|
||||||
|
gtype = banner.get("gachaType", "PickupGacha")
|
||||||
|
banner_name = f"{gtype} — {rateups}"
|
||||||
|
rates = get_rates_for_banner(banner)
|
||||||
|
|
||||||
|
# "Opening envelope" teaser
|
||||||
|
teaser = await ctx.reply("Opening recruitment envelope" + ("s..." if count > 1 else "..."))
|
||||||
|
|
||||||
|
# Perform pulls
|
||||||
|
pulls = []
|
||||||
|
for i in range(count):
|
||||||
|
# 10th pull guarantee
|
||||||
|
r = rates
|
||||||
|
if count == 10 and i == 9:
|
||||||
|
r = PULL10_RATES
|
||||||
|
|
||||||
|
rarity = roll_rarity(r)
|
||||||
|
student = db.random_by_rarity(rarity)
|
||||||
|
|
||||||
|
# Check for rate-up weighting (simplified: 30% chance to hit rate-up if 3★)
|
||||||
|
if banner and rarity == 3 and banner.get("rateups"):
|
||||||
|
rateup_names = [n.lower() for n in banner["rateups"]]
|
||||||
|
if random.random() < 0.30:
|
||||||
|
# Try to find the rate-up student in our DB
|
||||||
|
for rn in rateup_names:
|
||||||
|
s = db.get_by_name(rn)
|
||||||
|
if s and s["StarGrade"] == 3:
|
||||||
|
student = s
|
||||||
|
break
|
||||||
|
|
||||||
|
pulls.append(student)
|
||||||
|
|
||||||
|
# Update spark
|
||||||
|
user_state["spark"] = user_state.get("spark", 0) + count
|
||||||
|
# Trim history to last 50
|
||||||
|
history = user_state.get("history", [])
|
||||||
|
history.extend(p["Id"] for p in pulls)
|
||||||
|
user_state["history"] = history[-50:]
|
||||||
|
await _set_user_state(ctx.author.id, user_state)
|
||||||
|
|
||||||
|
# Render result image
|
||||||
|
spark = user_state["spark"]
|
||||||
|
async with ctx.typing():
|
||||||
|
img_bytes = await render_pull(pulls, banner_name, spark)
|
||||||
|
|
||||||
|
# Build summary embed
|
||||||
|
rarity_counts = {3: 0, 2: 0, 1: 0}
|
||||||
|
for p in pulls:
|
||||||
|
rarity_counts[p["StarGrade"]] += 1
|
||||||
|
|
||||||
|
summary_lines = [f"{p['StarGrade']}★ {p['Name']}" for p in pulls]
|
||||||
|
summary_text = "\n".join(summary_lines)
|
||||||
|
|
||||||
|
best = max(pulls, key=lambda p: p["StarGrade"])
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"{count}x Recruitment Result",
|
||||||
|
description=summary_text,
|
||||||
|
color={
|
||||||
|
3: 0xF0C040,
|
||||||
|
2: 0xB478DC,
|
||||||
|
1: 0x64A0DC,
|
||||||
|
}.get(best["StarGrade"], 0x5BA0D0),
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name="Summary",
|
||||||
|
value=f"3★: {rarity_counts[3]} | 2★: {rarity_counts[2]} | 1★: {rarity_counts[1]}",
|
||||||
|
)
|
||||||
|
embed.add_field(name="Banner", value=banner_name, inline=False)
|
||||||
|
embed.add_field(
|
||||||
|
name="Recruitment Points",
|
||||||
|
value=f"{spark}/{SPARK_TARGET} {'-- Spark Ready!' if spark >= SPARK_TARGET else ''}",
|
||||||
|
)
|
||||||
|
embed.set_footer(text=f"Top pull: {best['StarGrade']}★ {best['Name']} ({best.get('School', '')})")
|
||||||
|
|
||||||
|
# Send image and embed
|
||||||
|
file = discord.File(img_bytes, filename="gacha_result.png")
|
||||||
|
embed.set_image(url="attachment://gacha_result.png")
|
||||||
|
try:
|
||||||
|
await teaser.delete()
|
||||||
|
except (discord.NotFound, discord.Forbidden):
|
||||||
|
pass
|
||||||
|
await ctx.reply(file=file, embed=embed)
|
||||||
|
|
||||||
|
@commands.command(name="spark")
|
||||||
|
async def spark(self, ctx: commands.Context, *, character_name: str = "") -> None:
|
||||||
|
"""Claim a rate-up student from your active banner (costs 200 recruitment points).
|
||||||
|
|
||||||
|
Usage: !spark <character name>
|
||||||
|
"""
|
||||||
|
if not character_name:
|
||||||
|
await ctx.reply("Usage: `!spark <character name>` — e.g., `!spark Aru`")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_state = await _get_user_state(ctx.author.id)
|
||||||
|
spark = user_state.get("spark", 0)
|
||||||
|
|
||||||
|
if spark < SPARK_TARGET:
|
||||||
|
await ctx.reply(
|
||||||
|
f"You need {SPARK_TARGET} recruitment points to spark. You have {spark}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if character exists
|
||||||
|
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
|
||||||
|
user_state["spark"] = spark - SPARK_TARGET
|
||||||
|
await _set_user_state(ctx.author.id, user_state)
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
f"**Spark!** You claimed **{student['Name']}** ({student['StarGrade']}*, {student.get('School', 'Unknown')})!\n"
|
||||||
|
f"Remaining recruitment points: {user_state['spark']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@gacha.command(name="info")
|
||||||
|
async def gacha_info(self, ctx: commands.Context) -> None:
|
||||||
|
"""Show your current gacha state — active banner, spark count, and recent pulls."""
|
||||||
|
user_state = await _get_user_state(ctx.author.id)
|
||||||
|
banner_id = user_state.get("banner_id", "regular")
|
||||||
|
spark = user_state.get("spark", 0)
|
||||||
|
history = user_state.get("history", [])
|
||||||
|
|
||||||
|
# Banner name
|
||||||
|
banner_name = "Regular Recruitment"
|
||||||
|
if banner_id and banner_id != "regular":
|
||||||
|
for b in self._all_banners():
|
||||||
|
if b["id"] == banner_id:
|
||||||
|
rateups = ", ".join(b.get("rateups", [])) or "Standard Pool"
|
||||||
|
banner_name = f"{b.get('gachaType', 'PickupGacha')} — {rateups}"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Recent pulls
|
||||||
|
recent = []
|
||||||
|
for sid in reversed(history[-5:]):
|
||||||
|
s = db.get(sid)
|
||||||
|
if s:
|
||||||
|
recent.append(f"{'*' * s['StarGrade']} {s['Name']}")
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Your Gacha Status",
|
||||||
|
color=0x5BA0D0,
|
||||||
|
)
|
||||||
|
embed.add_field(name="Active Banner", value=banner_name, inline=False)
|
||||||
|
embed.add_field(
|
||||||
|
name="Recruitment Points", value=f"{spark}/{SPARK_TARGET}", inline=True
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name="Total Pulls", value=str(len(history)), inline=True
|
||||||
|
)
|
||||||
|
if recent:
|
||||||
|
embed.add_field(
|
||||||
|
name="Recent Pulls", value="\n".join(recent) or "None", inline=False
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.reply(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot: commands.Bot) -> None:
|
||||||
|
await bot.add_cog(BlueArchiveGacha(bot))
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
#this renders the gacha recruitment result screen using pillow, mimics the in-game look##
|
||||||
|
"""Pillow-based compositor: renders an in-game-style gacha recruitment result screen."""
|
||||||
|
######################################################################
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from .data import db
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Layout constants (1920×1080 canvas) ────────────────────────────────────
|
||||||
|
CANVAS_W, CANVAS_H = 1920, 1080
|
||||||
|
CARD_W, CARD_H = 280, 390
|
||||||
|
CARD_RADIUS = 10 # corner radius
|
||||||
|
GAP_X, GAP_Y = 25, 20
|
||||||
|
COLS, ROWS = 5, 2
|
||||||
|
GRID_W = COLS * CARD_W + (COLS - 1) * GAP_X # 1500
|
||||||
|
GRID_H = ROWS * CARD_H + (ROWS - 1) * GAP_Y # 800
|
||||||
|
GRID_X = (CANVAS_W - GRID_W) // 2 # 210
|
||||||
|
GRID_Y = 260 # below the header
|
||||||
|
HEADER_Y = 120
|
||||||
|
FOOTER_Y = GRID_Y + GRID_H + 50
|
||||||
|
|
||||||
|
STAR_BANNER_H = 36 # star rating strip at top of card
|
||||||
|
NAME_STRIP_H = 42 # name/school strip at bottom
|
||||||
|
PORTRAIT_TOP = STAR_BANNER_H + 4 # portrait starts below star banner
|
||||||
|
PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4
|
||||||
|
|
||||||
|
# Rarity colours
|
||||||
|
RARITY_COLORS = {
|
||||||
|
3: (255, 200, 60), # gold
|
||||||
|
2: (190, 130, 230), # purple
|
||||||
|
1: (110, 170, 230), # blue
|
||||||
|
}
|
||||||
|
RARITY_BG = {
|
||||||
|
3: (35, 28, 18),
|
||||||
|
2: (28, 18, 38),
|
||||||
|
1: (18, 22, 38),
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSETS_DIR = Path(__file__).parent / "assets"
|
||||||
|
BG_PATH = ASSETS_DIR / "gacha_bg.png"
|
||||||
|
|
||||||
|
# ── Font helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
||||||
|
"""Try to load a nice font; fall back to default."""
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
for fp in font_paths:
|
||||||
|
if Path(fp).exists():
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(fp, size)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
HEADER_FONT = _get_font(52, bold=True)
|
||||||
|
FOOTER_FONT = _get_font(18, bold=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Image fetching ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]:
|
||||||
|
"""Download a student's face portrait.
|
||||||
|
|
||||||
|
Tries the wiki CDN first (252x204 face image). Falls back to SchaleDB
|
||||||
|
full-body sprite with an upper-body crop on failure.
|
||||||
|
"""
|
||||||
|
student_id = student["Id"]
|
||||||
|
|
||||||
|
# 1. Try wiki face portrait
|
||||||
|
wiki_url = db.wiki_portrait(student)
|
||||||
|
try:
|
||||||
|
async with session.get(wiki_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
data = await resp.read()
|
||||||
|
return Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
|
except Exception:
|
||||||
|
log.debug("Wiki portrait failed for %s, trying SchaleDB fallback", student["Name"])
|
||||||
|
|
||||||
|
# 2. Fallback: SchaleDB full-body sprite with upper-body crop
|
||||||
|
schale_url = db.schale_portrait(student_id)
|
||||||
|
try:
|
||||||
|
async with session.get(schale_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
data = await resp.read()
|
||||||
|
img = Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
|
# Crop top portion for face/upper-body (source is ~614x1024)
|
||||||
|
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))
|
||||||
|
return img
|
||||||
|
except Exception:
|
||||||
|
log.exception("Failed to fetch portrait for student %d", student_id)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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]]:
|
||||||
|
"""Return vertices for a 5-pointed star centered at (cx, cy)."""
|
||||||
|
import math
|
||||||
|
points = []
|
||||||
|
for i in range(10):
|
||||||
|
angle = math.pi / 2 + i * math.pi / 5 # start from top
|
||||||
|
r = outer_r if i % 2 == 0 else inner_r
|
||||||
|
x = cx + r * math.cos(angle)
|
||||||
|
y = cy - r * math.sin(angle)
|
||||||
|
points.append((int(x), int(y)))
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_star_icons(
|
||||||
|
draw: ImageDraw.Draw,
|
||||||
|
cx: int,
|
||||||
|
cy: int,
|
||||||
|
count: int,
|
||||||
|
color: tuple,
|
||||||
|
size: int = 7,
|
||||||
|
) -> None:
|
||||||
|
"""Draw <count> gold 5-pointed star icons horizontally centered at (cx, cy)."""
|
||||||
|
outer = size
|
||||||
|
inner = int(size * 0.38)
|
||||||
|
spacing = int(size * 2.4)
|
||||||
|
total_w = (count - 1) * spacing
|
||||||
|
start_x = cx - total_w // 2
|
||||||
|
for i in range(count):
|
||||||
|
pts = _star_polygon(start_x + i * spacing, cy, outer, inner)
|
||||||
|
# Shadow
|
||||||
|
draw.polygon([(p[0] + 1, p[1] + 1) for p in pts], fill=(0, 0, 0, 100))
|
||||||
|
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 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def render_pull(
|
||||||
|
pulls: list[dict],
|
||||||
|
banner_name: str = "Regular Recruitment",
|
||||||
|
spark_count: int = 0,
|
||||||
|
) -> io.BytesIO:
|
||||||
|
"""Render a 10-pull (or single-pull) result as a PNG image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pulls: List of student dicts from StudentDB (must have Id, Name, StarGrade).
|
||||||
|
banner_name: Display name for the banner header.
|
||||||
|
spark_count: Current spark/pity count to show in the footer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BytesIO containing the rendered PNG image.
|
||||||
|
"""
|
||||||
|
# Load background
|
||||||
|
if BG_PATH.exists():
|
||||||
|
canvas = Image.open(BG_PATH).convert("RGBA")
|
||||||
|
else:
|
||||||
|
canvas = Image.new("RGBA", (CANVAS_W, CANVAS_H), (180, 226, 245))
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
|
||||||
|
# ── Header ──────────────────────────────────────────────────────────
|
||||||
|
_draw_header(draw, banner_name)
|
||||||
|
|
||||||
|
# ── Footer / spark ──────────────────────────────────────────────────
|
||||||
|
_draw_footer(draw, spark_count)
|
||||||
|
|
||||||
|
# ── Fetch all portraits ─────────────────────────────────────────────
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
portrait_tasks = [_fetch_portrait(session, p) for p in pulls]
|
||||||
|
portraits = await asyncio.gather(*portrait_tasks)
|
||||||
|
|
||||||
|
# ── Draw cards in grid ──────────────────────────────────────────────
|
||||||
|
for idx, (student, portrait) in enumerate(zip(pulls, portraits)):
|
||||||
|
col = idx % COLS
|
||||||
|
row = idx // COLS
|
||||||
|
cx = GRID_X + col * (CARD_W + GAP_X)
|
||||||
|
cy = GRID_Y + row * (CARD_H + GAP_Y)
|
||||||
|
_draw_card(canvas, draw, cx, cy, student, portrait)
|
||||||
|
|
||||||
|
# ── Encode ──────────────────────────────────────────────────────────
|
||||||
|
output = io.BytesIO()
|
||||||
|
canvas.convert("RGB").save(output, format="PNG", optimize=True)
|
||||||
|
output.seek(0)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_header(draw: ImageDraw.Draw, banner_name: str) -> None:
|
||||||
|
"""Draw the 'Recruitment Results' header at the top."""
|
||||||
|
title = "Recruitment Results"
|
||||||
|
tw = draw.textlength(title, font=HEADER_FONT)
|
||||||
|
|
||||||
|
# Banner name subtitle
|
||||||
|
subtitle = banner_name
|
||||||
|
sub_font = _get_font(26, bold=False)
|
||||||
|
|
||||||
|
# Shadow + text
|
||||||
|
draw.text(((CANVAS_W - tw) / 2 + 2, HEADER_Y + 2), title, fill=(0, 0, 0, 100), font=HEADER_FONT)
|
||||||
|
draw.text(((CANVAS_W - tw) / 2, HEADER_Y), title, fill=(255, 255, 255, 240), font=HEADER_FONT)
|
||||||
|
|
||||||
|
# Subtitle
|
||||||
|
sw = draw.textlength(subtitle, font=sub_font)
|
||||||
|
draw.text(((CANVAS_W - sw) / 2, HEADER_Y + 60), subtitle, fill=(255, 255, 255, 200), font=sub_font)
|
||||||
|
|
||||||
|
# Decorative line under header
|
||||||
|
line_y = HEADER_Y + 100
|
||||||
|
draw.line(
|
||||||
|
[(GRID_X, line_y), (GRID_X + GRID_W, line_y)],
|
||||||
|
fill=(255, 255, 255, 120),
|
||||||
|
width=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_footer(draw: ImageDraw.Draw, spark_count: int) -> None:
|
||||||
|
"""Draw spark counter at the bottom."""
|
||||||
|
text = f"Recruitment Points: {spark_count} / 200"
|
||||||
|
tw = draw.textlength(text, font=FOOTER_FONT)
|
||||||
|
draw.text(
|
||||||
|
((CANVAS_W - tw) / 2 + 1, FOOTER_Y + 1),
|
||||||
|
text,
|
||||||
|
fill=(0, 0, 0, 80),
|
||||||
|
font=FOOTER_FONT,
|
||||||
|
)
|
||||||
|
draw.text(
|
||||||
|
((CANVAS_W - tw) / 2, FOOTER_Y),
|
||||||
|
text,
|
||||||
|
fill=(255, 255, 255, 200),
|
||||||
|
font=FOOTER_FONT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_card(
|
||||||
|
canvas: Image.Image,
|
||||||
|
draw: ImageDraw.Draw,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
student: dict,
|
||||||
|
portrait: Optional[Image.Image],
|
||||||
|
) -> None:
|
||||||
|
"""Draw a single recruitment result card at (x, y)."""
|
||||||
|
rarity = student["StarGrade"]
|
||||||
|
color = RARITY_COLORS.get(rarity, (150, 150, 150))
|
||||||
|
bg = RARITY_BG.get(rarity, (20, 20, 20))
|
||||||
|
|
||||||
|
# Card background with rounded corners
|
||||||
|
_draw_rounded_rect(draw, (x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, fill=bg + (255,))
|
||||||
|
# Card border
|
||||||
|
_draw_rounded_rect(
|
||||||
|
draw, (x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, outline=color, width=2
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Star banner strip at top ────────────────────────────────────────
|
||||||
|
banner_top = y + 1
|
||||||
|
banner_bottom = y + STAR_BANNER_H
|
||||||
|
# Semi-transparent dark strip
|
||||||
|
_draw_rounded_rect(
|
||||||
|
draw,
|
||||||
|
(x + 3, banner_top, x + CARD_W - 3, banner_bottom),
|
||||||
|
radius=CARD_RADIUS - 2,
|
||||||
|
fill=(0, 0, 0, 80),
|
||||||
|
)
|
||||||
|
# Draw star icons centered in the banner
|
||||||
|
_draw_star_icons(
|
||||||
|
draw,
|
||||||
|
x + CARD_W // 2,
|
||||||
|
y + STAR_BANNER_H // 2,
|
||||||
|
rarity,
|
||||||
|
color,
|
||||||
|
size=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Portrait area ───────────────────────────────────────────────────
|
||||||
|
px = x + 6
|
||||||
|
py = y + PORTRAIT_TOP
|
||||||
|
pw = CARD_W - 12
|
||||||
|
ph = PORTRAIT_H
|
||||||
|
|
||||||
|
if portrait:
|
||||||
|
portrait = portrait.copy()
|
||||||
|
p_w, p_h = portrait.size
|
||||||
|
scale = min(pw / p_w, ph / p_h)
|
||||||
|
new_w, new_h = int(p_w * scale), int(p_h * scale)
|
||||||
|
portrait = portrait.resize((new_w, new_h), Image.LANCZOS)
|
||||||
|
pox = px + (pw - new_w) // 2
|
||||||
|
poy = py + (ph - new_h) // 2
|
||||||
|
canvas.paste(portrait, (pox, poy), portrait if portrait.mode == "RGBA" else None)
|
||||||
|
else:
|
||||||
|
_draw_rounded_rect(draw, (px, py, px + pw, py + ph), radius=6, fill=(30, 30, 40, 180))
|
||||||
|
ph_font = _get_font(16)
|
||||||
|
msg = "No Image"
|
||||||
|
mw = draw.textlength(msg, font=ph_font)
|
||||||
|
draw.text((px + (pw - mw) / 2, py + ph / 2 - 10), msg, fill=(150, 150, 150), font=ph_font)
|
||||||
|
|
||||||
|
# ── Name strip at bottom ────────────────────────────────────────────
|
||||||
|
name_y = y + CARD_H - NAME_STRIP_H + 2
|
||||||
|
_draw_rounded_rect(
|
||||||
|
draw,
|
||||||
|
(x + 3, name_y, x + CARD_W - 3, y + CARD_H - 3),
|
||||||
|
radius=CARD_RADIUS - 3,
|
||||||
|
fill=(0, 0, 0, 150),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Student name
|
||||||
|
name = student["Name"]
|
||||||
|
name_font = _get_font(20, bold=True)
|
||||||
|
nw = draw.textlength(name, font=name_font)
|
||||||
|
draw.text(
|
||||||
|
(x + (CARD_W - nw) / 2 + 1, name_y + 2),
|
||||||
|
name,
|
||||||
|
fill=(0, 0, 0),
|
||||||
|
font=name_font,
|
||||||
|
)
|
||||||
|
draw.text(
|
||||||
|
(x + (CARD_W - nw) / 2, name_y + 1),
|
||||||
|
name,
|
||||||
|
fill=(255, 255, 255),
|
||||||
|
font=name_font,
|
||||||
|
)
|
||||||
|
|
||||||
|
# School label
|
||||||
|
school = student.get("School", "")
|
||||||
|
if school:
|
||||||
|
sch_font = _get_font(14)
|
||||||
|
sw2 = draw.textlength(school, font=sch_font)
|
||||||
|
draw.text(
|
||||||
|
(x + (CARD_W - sw2) / 2 + 1, name_y + 23),
|
||||||
|
school,
|
||||||
|
fill=(0, 0, 0),
|
||||||
|
font=sch_font,
|
||||||
|
)
|
||||||
|
draw.text(
|
||||||
|
(x + (CARD_W - sw2) / 2, name_y + 22),
|
||||||
|
school,
|
||||||
|
fill=color + (200,),
|
||||||
|
font=sch_font,
|
||||||
|
)
|
||||||
+1
-28
@@ -18,7 +18,7 @@ class Fun(commands.Cog):
|
|||||||
"!coin - flips a coin\n"
|
"!coin - flips a coin\n"
|
||||||
"!rps <choice> - rock paper scissors\n"
|
"!rps <choice> - rock paper scissors\n"
|
||||||
"!roulette - russian roulette\n"
|
"!roulette - russian roulette\n"
|
||||||
"!pull - 10 pull gacha\n"
|
"!gacha - blue archive recruitment simulator\n"
|
||||||
"!slots - slot machine\n"
|
"!slots - slot machine\n"
|
||||||
"!ship @user - love meter\n"
|
"!ship @user - love meter\n"
|
||||||
"!pp - measures pp size\n"
|
"!pp - measures pp size\n"
|
||||||
@@ -175,32 +175,5 @@ class Fun(commands.Cog):
|
|||||||
percentage = random.randint(0, 100)
|
percentage = random.randint(0, 100)
|
||||||
await ctx.reply(f"🛳️ **Ship:** {user1} x {user2}\n**Compatibility:** {percentage}%")
|
await ctx.reply(f"🛳️ **Ship:** {user1} x {user2}\n**Compatibility:** {percentage}%")
|
||||||
|
|
||||||
# 10 Pull Gacha (5% Purple, 19% Yellow, 76% Blue. 10th is guaranteed 97% Y / 3% P)
|
|
||||||
@commands.command()
|
|
||||||
async def pull(self, ctx):
|
|
||||||
results = []
|
|
||||||
for i in range(10):
|
|
||||||
roll = random.randint(1, 100)
|
|
||||||
# The 10th pull (index 9) uses different odds
|
|
||||||
if i == 9:
|
|
||||||
if roll <= 3:
|
|
||||||
results.append('🟪')
|
|
||||||
else:
|
|
||||||
results.append('🟨')
|
|
||||||
else:
|
|
||||||
if roll <= 5:
|
|
||||||
results.append('🟪')
|
|
||||||
elif roll <= 24:
|
|
||||||
results.append('🟨')
|
|
||||||
else:
|
|
||||||
results.append('🟦')
|
|
||||||
|
|
||||||
# Using En-Space (\u2002) so Discord doesn't collapse it
|
|
||||||
row1 = "\u2002".join(results[0:5])
|
|
||||||
row2 = "\u2002".join(results[5:10])
|
|
||||||
|
|
||||||
await ctx.reply(f"{row1}\n\n{row2}")
|
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
await bot.add_cog(Fun(bot))
|
await bot.add_cog(Fun(bot))
|
||||||
@@ -68,6 +68,7 @@ async def main():
|
|||||||
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("rio.ssh_connection_rio")
|
||||||
|
await bot.load_extension('blue_archive.gacha')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await bot.start(os.getenv('DISCORD_TOKEN'))
|
await bot.start(os.getenv('DISCORD_TOKEN'))
|
||||||
|
|||||||
Reference in new issue
Block a user