From 95228a425de63b851e98ad9cc28eae722b99481a Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 11 Jul 2026 17:57:11 +0300 Subject: [PATCH] add blue archive gacha simulator cog with live banners and wiki portraits --- .gitignore | 2 + AGENTS.md | 69 ++++++ README.md | 3 +- blue_archive/__init__.py | 1 + blue_archive/data.py | 212 ++++++++++++++++++ blue_archive/gacha.py | 386 +++++++++++++++++++++++++++++++++ blue_archive/gacha_renderer.py | 371 +++++++++++++++++++++++++++++++ cogs/chance.py | 29 +-- main.py | 1 + 9 files changed, 1045 insertions(+), 29 deletions(-) create mode 100644 AGENTS.md create mode 100644 blue_archive/__init__.py create mode 100644 blue_archive/data.py create mode 100644 blue_archive/gacha.py create mode 100644 blue_archive/gacha_renderer.py diff --git a/.gitignore b/.gitignore index d7f3246..6c43e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ yuuka-radio-streams.md esef_learns.md icy_supported.json icy_metadata_radios.md +__pycache__/ +ssh_update.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..36faa3f --- /dev/null +++ b/AGENTS.md @@ -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.')` +- **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). diff --git a/README.md b/README.md index 4caee34..15e4916 100644 --- a/README.md +++ b/README.md @@ -28,4 +28,5 @@ run these to see the sub-commands: - `!music` — music playback controls - `!sysinfo` — hardware monitoring - `!gifs` — the gif list -- `!fun` — the junk commands \ No newline at end of file +- `!fun` — the junk commands +- `!gacha` — blue archive recruitment simulator (pick banners, pull with live rates) \ No newline at end of file diff --git a/blue_archive/__init__.py b/blue_archive/__init__.py new file mode 100644 index 0000000..c11d293 --- /dev/null +++ b/blue_archive/__init__.py @@ -0,0 +1 @@ +"""Blue Archive gacha simulator package.""" diff --git a/blue_archive/data.py b/blue_archive/data.py new file mode 100644 index 0000000..f3f95ab --- /dev/null +++ b/blue_archive/data.py @@ -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}" + ) diff --git a/blue_archive/gacha.py b/blue_archive/gacha.py new file mode 100644 index 0000000..c4ffe2b --- /dev/null +++ b/blue_archive/gacha.py @@ -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 ` 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 or !gacha pick regular + """ + choice = choice.strip().lower() + if not choice: + await ctx.reply("Usage: `!gacha pick ` 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 ` 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 + """ + if not character_name: + await ctx.reply("Usage: `!spark ` — 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)) diff --git a/blue_archive/gacha_renderer.py b/blue_archive/gacha_renderer.py new file mode 100644 index 0000000..ae42fe0 --- /dev/null +++ b/blue_archive/gacha_renderer.py @@ -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 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, + ) diff --git a/cogs/chance.py b/cogs/chance.py index 3957efc..9d3b5d9 100644 --- a/cogs/chance.py +++ b/cogs/chance.py @@ -18,7 +18,7 @@ class Fun(commands.Cog): "!coin - flips a coin\n" "!rps - rock paper scissors\n" "!roulette - russian roulette\n" - "!pull - 10 pull gacha\n" + "!gacha - blue archive recruitment simulator\n" "!slots - slot machine\n" "!ship @user - love meter\n" "!pp - measures pp size\n" @@ -175,32 +175,5 @@ class Fun(commands.Cog): percentage = random.randint(0, 100) 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): await bot.add_cog(Fun(bot)) \ No newline at end of file diff --git a/main.py b/main.py index 0e8f967..dbdfde7 100644 --- a/main.py +++ b/main.py @@ -68,6 +68,7 @@ async def main(): await bot.load_extension('ai.ai_roleplay') await bot.load_extension('twitterbridge.leaks_cmd') await bot.load_extension("rio.ssh_connection_rio") + await bot.load_extension('blue_archive.gacha') try: await bot.start(os.getenv('DISCORD_TOKEN'))