From 11d7c139e67e0644c294bbb9ad617980505a6c62 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 11 Jul 2026 19:14:52 +0300 Subject: [PATCH] add sqlite inventory, per-banner spark, eligma, collection commands --- blue_archive/db.py | 161 ++++++++++++++++++++++++++++++++++++++ blue_archive/gacha.py | 135 +++++++++++++++++--------------- blue_archive/inventory.py | 156 ++++++++++++++++++++++++++++++++++++ main.py | 1 + 4 files changed, 388 insertions(+), 65 deletions(-) create mode 100644 blue_archive/db.py create mode 100644 blue_archive/inventory.py diff --git a/blue_archive/db.py b/blue_archive/db.py new file mode 100644 index 0000000..463bd5c --- /dev/null +++ b/blue_archive/db.py @@ -0,0 +1,161 @@ +#this is the sqlite database layer for gacha inventory, spark, and eligma## +"""SQLite database for Blue Archive gacha persistence.""" +###################################################################### +import sqlite3 +import logging +from pathlib import Path + +log = logging.getLogger(__name__) + +DB_PATH = Path("data/ba_gacha.db") + +# Eligma earned per duplicate by rarity +ELIGMA_YIELD = {1: 1, 2: 10, 3: 50} + + +def _connect() -> sqlite3.Connection: + """Open a connection with WAL mode for concurrent reads.""" + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db() -> None: + """Create tables if they don't exist.""" + with _connect() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS inventory ( + user_id INTEGER NOT NULL, + student_id INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, student_id) + ); + + CREATE TABLE IF NOT EXISTS spark ( + user_id INTEGER NOT NULL, + banner_id TEXT NOT NULL, + points INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, banner_id) + ); + + CREATE TABLE IF NOT EXISTS eligma ( + user_id INTEGER PRIMARY KEY, + amount INTEGER NOT NULL DEFAULT 0 + ); + """) + log.info("Database initialised at %s", DB_PATH) + + +# ── Inventory ────────────────────────────────────────────────────────────── + +def add_pull(user_id: int, student_id: int, rarity: int) -> int: + """Record a pull. If student already owned, increment count and return eligma earned.""" + with _connect() as conn: + cur = conn.execute( + "SELECT count FROM inventory WHERE user_id = ? AND student_id = ?", + (user_id, student_id), + ) + row = cur.fetchone() + if row: + # Duplicate — increment and earn eligma + conn.execute( + "UPDATE inventory SET count = count + 1 WHERE user_id = ? AND student_id = ?", + (user_id, student_id), + ) + eligma = ELIGMA_YIELD.get(rarity, 0) + _add_eligma(conn, user_id, eligma) + return eligma + else: + # New student + conn.execute( + "INSERT INTO inventory (user_id, student_id, count) VALUES (?, ?, 1)", + (user_id, student_id), + ) + return 0 + + +def get_inventory(user_id: int) -> list[tuple[int, int]]: + """Return [(student_id, count), ...] for a user.""" + with _connect() as conn: + rows = conn.execute( + "SELECT student_id, count FROM inventory WHERE user_id = ? ORDER BY student_id", + (user_id,), + ).fetchall() + return rows + + +def get_inventory_stats(user_id: int) -> dict: + """Return {unique_total, total_pulls, by_rarity: {1: n, 2: n, 3: n}}.""" + with _connect() as conn: + total_pulls = conn.execute( + "SELECT COALESCE(SUM(count), 0) FROM inventory WHERE user_id = ?", + (user_id,), + ).fetchone()[0] + unique = conn.execute( + "SELECT COUNT(*) FROM inventory WHERE user_id = ?", + (user_id,), + ).fetchone()[0] + return {"total_pulls": total_pulls, "unique": unique} + + +# ── Spark ─────────────────────────────────────────────────────────────────── + +def get_spark(user_id: int, banner_id: str) -> int: + with _connect() as conn: + row = conn.execute( + "SELECT points FROM spark WHERE user_id = ? AND banner_id = ?", + (user_id, banner_id), + ).fetchone() + return row[0] if row else 0 + + +def add_spark(user_id: int, banner_id: str, points: int) -> int: + """Add points to a banner's spark counter. Returns new total.""" + with _connect() as conn: + conn.execute( + "INSERT INTO spark (user_id, banner_id, points) VALUES (?, ?, ?) " + "ON CONFLICT(user_id, banner_id) DO UPDATE SET points = points + ?", + (user_id, banner_id, points, points), + ) + conn.commit() + row = conn.execute( + "SELECT points FROM spark WHERE user_id = ? AND banner_id = ?", + (user_id, banner_id), + ).fetchone() + return row[0] if row else 0 + + +def spend_spark(user_id: int, banner_id: str, cost: int = 200) -> bool: + """Deduct spark points. Returns True if successful (had enough points).""" + with _connect() as conn: + row = conn.execute( + "SELECT points FROM spark WHERE user_id = ? AND banner_id = ?", + (user_id, banner_id), + ).fetchone() + if not row or row[0] < cost: + return False + conn.execute( + "UPDATE spark SET points = points - ? WHERE user_id = ? AND banner_id = ?", + (cost, user_id, banner_id), + ) + return True + + +# ── Eligma ────────────────────────────────────────────────────────────────── + +def _add_eligma(conn: sqlite3.Connection, user_id: int, amount: int) -> None: + conn.execute( + "INSERT INTO eligma (user_id, amount) VALUES (?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET amount = amount + ?", + (user_id, amount, amount), + ) + + +def get_eligma(user_id: int) -> int: + with _connect() as conn: + row = conn.execute( + "SELECT amount FROM eligma WHERE user_id = ?", (user_id,) + ).fetchone() + return row[0] if row else 0 diff --git a/blue_archive/gacha.py b/blue_archive/gacha.py index 12cd8cc..3cba764 100644 --- a/blue_archive/gacha.py +++ b/blue_archive/gacha.py @@ -2,15 +2,14 @@ """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 . import db as gacha_db from .data import ( db, fetch_banners, @@ -24,40 +23,35 @@ 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() +BANNER_FILE = Path("data/ba_banner_state.json") -def _load_state() -> dict: - """Load per-user gacha state from disk.""" - if STATE_FILE.exists(): - with open(STATE_FILE, "r") as f: +def _load_banner_state() -> dict: + """Which banner each user has picked. Lightweight JSON, separate from SQLite.""" + if BANNER_FILE.exists(): + import json + with open(BANNER_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: +def _save_banner_state(state: dict) -> None: + import json + BANNER_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(BANNER_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] +def _get_banner_id(user_id: int) -> Optional[str]: + state = _load_banner_state() + return state.get(str(user_id)) -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) +def _set_banner_id(user_id: int, banner_id: Optional[str]) -> None: + state = _load_banner_state() + state[str(user_id)] = banner_id + _save_banner_state(state) class BlueArchiveGacha(commands.Cog): @@ -71,10 +65,10 @@ class BlueArchiveGacha(commands.Cog): # ── Lifecycle ─────────────────────────────────────────────────────── async def cog_load(self) -> None: - """Load student DB and fetch banners on startup.""" + """Load student DB, init SQLite, and fetch banners on startup.""" db.load() + gacha_db.init_db() 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: @@ -150,9 +144,7 @@ class BlueArchiveGacha(commands.Cog): 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) + _set_banner_id(ctx.author.id, "regular") await ctx.reply( "Active banner set to **Regular Recruitment** (permanent pool, no rate-up).\n" "Use `!pull` to recruit!" @@ -180,15 +172,15 @@ class BlueArchiveGacha(commands.Cog): 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) + _set_banner_id(ctx.author.id, str(banner["id"])) + + # Show current spark for this banner + spark = gacha_db.get_spark(ctx.author.id, str(banner["id"])) await ctx.reply( f"Active banner: **{gtype}**\n" f"Rate-up: {rateups}\n" - f"Spark count reset to 0. Use `!pull` to recruit!" + f"Recruitment Points: {spark}/{SPARK_TARGET}. Use `!pull` to recruit!" ) @commands.command(name="pull") @@ -204,8 +196,8 @@ class BlueArchiveGacha(commands.Cog): else: count = 10 - user_state = await _get_user_state(ctx.author.id) - banner_id = user_state.get("banner_id") + user_id = ctx.author.id + banner_id = _get_banner_id(user_id) # Determine banner info try: @@ -255,16 +247,19 @@ class BlueArchiveGacha(commands.Cog): 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) + # Save pulls to inventory (eligma for dupes) and update spark + user_id = ctx.author.id + spark_banner = str(banner_id) if banner_id else "regular" + spark = gacha_db.add_spark(user_id, spark_banner, count) + + eligma_earned = 0 + for p in pulls: + e = gacha_db.add_pull(user_id, p["Id"], p["StarGrade"]) + eligma_earned += e + + dupe_msg = f" | +{eligma_earned} Eligma from dupes" if eligma_earned else "" # Render result image - spark = user_state["spark"] try: async with ctx.typing(): img_bytes = await render_pull(pulls, banner_name, spark) @@ -277,21 +272,24 @@ class BlueArchiveGacha(commands.Cog): await ctx.reply(f"Render failed: {e}") return - # Send image only + # Send image with spark+eligma caption 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) + await ctx.reply( + f"Recruitment Points: {spark}/{SPARK_TARGET}{dupe_msg}", + 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). + """Claim a rate-up student using 200 Recruitment Points from your active banner. Usage: !spark """ @@ -299,12 +297,14 @@ class BlueArchiveGacha(commands.Cog): 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) + user_id = ctx.author.id + banner_id = str(_get_banner_id(user_id) or "regular") + spark = gacha_db.get_spark(user_id, banner_id) if spark < SPARK_TARGET: await ctx.reply( - f"You need {SPARK_TARGET} recruitment points to spark. You have {spark}." + f"You need {SPARK_TARGET} Recruitment Points to spark. " + f"You have {spark} on this banner." ) return @@ -314,22 +314,25 @@ class BlueArchiveGacha(commands.Cog): 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) + # Deduct spark and add student to inventory + if not gacha_db.spend_spark(user_id, banner_id): + await ctx.reply("Not enough Recruitment Points.") + return + gacha_db.add_pull(user_id, student["Id"], student["StarGrade"]) + remaining = gacha_db.get_spark(user_id, banner_id) await ctx.reply( - f"**Spark!** You claimed **{student['Name']}** ({student['StarGrade']}*, {student.get('School', 'Unknown')})!\n" - f"Remaining recruitment points: {user_state['spark']}" + f"**Spark!** You claimed **{student['Name']}** " + f"({student['StarGrade']}*, {student.get('School', 'Unknown')})!\n" + f"Remaining Recruitment Points: {remaining}" ) @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", []) + """Show your active banner, spark count, and collection summary.""" + user_id = ctx.author.id + banner_id = str(_get_banner_id(user_id) or "regular") + spark = gacha_db.get_spark(user_id, banner_id) # Banner name banner_name = "Regular Recruitment" @@ -356,12 +359,14 @@ class BlueArchiveGacha(commands.Cog): name="Recruitment Points", value=f"{spark}/{SPARK_TARGET}", inline=True ) embed.add_field( - name="Total Pulls", value=str(len(history)), inline=True + name="Eligma", value=str(gacha_db.get_eligma(user_id)), inline=True + ) + stats = gacha_db.get_inventory_stats(user_id) + embed.add_field( + name="Collection", + value=f"{stats['unique']} unique students from {stats['total_pulls']} total pulls", + inline=False, ) - if recent: - embed.add_field( - name="Recent Pulls", value="\n".join(recent) or "None", inline=False - ) await ctx.reply(embed=embed) diff --git a/blue_archive/inventory.py b/blue_archive/inventory.py new file mode 100644 index 0000000..443ab1d --- /dev/null +++ b/blue_archive/inventory.py @@ -0,0 +1,156 @@ +#this is the inventory cog for viewing collections and eligma balance## +"""Collection and eligma commands for the Blue Archive gacha system.""" +###################################################################### +import logging + +import discord +from discord.ext import commands + +from . import db as gacha_db +from .data import db + +log = logging.getLogger(__name__) + +ELIGMA_YIELD = {1: 1, 2: 10, 3: 50} + + +class Inventory(commands.Cog): + """View your student collection and eligma from gacha pulls.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @commands.group(name="inv", aliases=["collection", "i"], invoke_without_command=True) + async def inventory(self, ctx: commands.Context, *, target: str = "") -> None: + """View your student collection grouped by rarity. + + Usage: + !inv — your collection + !inv @user — someone else's + !inv 3star — filter to 3★ only + !inv stats — summary stats + """ + # Determine whose collection to show + if target and target.startswith("<@"): + try: + uid = int(target.strip("<@!>")) + except ValueError: + uid = ctx.author.id + else: + uid = ctx.author.id + + filter_rarity = None + if target in ("3star", "3", "3*", "3star"): + filter_rarity = 3 + elif target in ("2star", "2", "2*", "2star"): + filter_rarity = 2 + elif target in ("1star", "1", "1*", "1star"): + filter_rarity = 1 + + rows = gacha_db.get_inventory(uid) + if not rows: + if uid == ctx.author.id: + await ctx.reply("Your collection is empty. Use `!pull` to recruit students!") + else: + await ctx.reply("This user's collection is empty.") + return + + # Group by rarity + by_rarity: dict[int, list[tuple[int, str, int]]] = {3: [], 2: [], 1: []} + for sid, count in rows: + s = db.get(sid) + if s: + r = s["StarGrade"] + by_rarity.get(r, []).append((sid, s["Name"], count)) + + # Build embed + title = f"Collection of {self.bot.get_user(uid).display_name if self.bot.get_user(uid) else 'User'}" + embed = discord.Embed(title=title, color=0x5BA0D0) + + for rarity, label, em_color in [ + (3, "3-star", 0xF0C0FF), + (2, "2-star", 0xFFF588), + (1, "1-star", 0xAADCFF), + ]: + if filter_rarity and rarity != filter_rarity: + continue + chars = sorted(by_rarity[rarity], key=lambda x: -x[2]) + if not chars: + continue + total_unique = len(chars) + total_dupes = sum(c[2] - 1 for c in chars) + header = f"{label} — {total_unique} unique" + if total_dupes: + header += f" (+{total_dupes} dupes)" + + # Format: "Aru x3, Hina x1, ..." + lines = [] + for _, name, count in chars: + lines.append(f"{name}" + (f" x{count}" if count > 1 else "")) + + # Split into chunks of ~10 per embed field line + text = ", ".join(lines) + if len(text) > 1024: + text = text[:1021] + "..." + embed.add_field(name=header, value=text, inline=False) + + # Footer stats + stats = gacha_db.get_inventory_stats(uid) + eligma = gacha_db.get_eligma(uid) + embed.set_footer( + text=f"{stats['unique']} unique · {stats['total_pulls']} total pulls · {eligma} Eligma" + ) + + await ctx.reply(embed=embed) + + @inventory.command(name="stats") + async def inv_stats(self, ctx: commands.Context) -> None: + """Show your collection summary stats.""" + uid = ctx.author.id + stats = gacha_db.get_inventory_stats(uid) + eligma = gacha_db.get_eligma(uid) + + # Count by rarity + rows = gacha_db.get_inventory(uid) + by_rarity = {1: 0, 2: 0, 3: 0} + for sid, _ in rows: + s = db.get(sid) + if s: + by_rarity[s["StarGrade"]] = by_rarity.get(s["StarGrade"], 0) + 1 + + embed = discord.Embed(title="Collection Stats", color=0x5BA0D0) + embed.add_field(name="Total Pulls", value=str(stats["total_pulls"]), inline=True) + embed.add_field(name="Unique Students", value=str(stats["unique"]), inline=True) + embed.add_field(name="Eligma", value=str(eligma), inline=True) + embed.add_field( + name="By Rarity", + value=f"3★: {by_rarity[3]} · 2★: {by_rarity[2]} · 1★: {by_rarity[1]}", + inline=False, + ) + + await ctx.reply(embed=embed) + + @commands.command(name="eligma", aliases=["elig"]) + async def eligma(self, ctx: commands.Context) -> None: + """Show your eligma balance and explain how it works.""" + uid = ctx.author.id + amount = gacha_db.get_eligma(uid) + + embed = discord.Embed( + title="Eligma", + description=( + f"**Balance: {amount}**\n\n" + "Eligma is earned from duplicate pulls:\n" + f"1★ dupe = {ELIGMA_YIELD[1]} Eligma\n" + f"2★ dupe = {ELIGMA_YIELD[2]} Eligma\n" + f"3★ dupe = {ELIGMA_YIELD[3]} Eligma\n\n" + "In the real game, Eligma is used to buy student shards " + "in the shop. (Shop coming soon.)" + ), + color=0x5BA0D0, + ) + await ctx.reply(embed=embed) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Inventory(bot)) diff --git a/main.py b/main.py index dbdfde7..dd66f6d 100644 --- a/main.py +++ b/main.py @@ -69,6 +69,7 @@ async def main(): await bot.load_extension('twitterbridge.leaks_cmd') await bot.load_extension("rio.ssh_connection_rio") await bot.load_extension('blue_archive.gacha') + await bot.load_extension('blue_archive.inventory') try: await bot.start(os.getenv('DISCORD_TOKEN'))