Files
discordbot/blue_archive/gacha.py
T

371 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 currently active recruitment banners from the live API."""
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:
"""Pull on your active recruitment banner.
Usage: !pull (10-pull with guaranteed 2★+ on 10th)
!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 "..."))
# Build the banner-specific character pool
if banner_id and banner_id != "regular" and banner:
pool = db.build_pool(banner)
rateup_names = [n.lower() for n in banner.get("rateups", [])]
else:
pool = db.build_pool(None) # regular recruitment
rateup_names = []
# 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)
rarity_pool = pool.get(rarity, [])
if rarity_pool:
student = db.weighted_pick(rarity_pool, rateup_names, rarity)
else:
student = db.random_by_rarity(rarity) # fallback
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"]
try:
async with ctx.typing():
img_bytes = await render_pull(pulls, banner_name, spark)
except Exception as e:
log.exception("Render failed")
try:
await teaser.delete()
except (discord.NotFound, discord.Forbidden):
pass
await ctx.reply(f"Render failed: {e}")
return
# Send image only
try:
file = discord.File(img_bytes, filename="gacha_result.png")
try:
await teaser.delete()
except (discord.NotFound, discord.Forbidden):
pass
await ctx.reply(file=file)
except Exception as e:
log.exception("Failed to send result image")
await ctx.reply(f"Failed to send result: {e}")
@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))