add sqlite inventory, per-banner spark, eligma, collection commands

This commit is contained in:
esef committed 2026-07-11 19:14:52 +03:00
1 parent e61b0f1d88
commit 11d7c139e6
4 files changed
+386 -63

No files matched your search

+161
View File
@@ -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
+68 -63
View File
@@ -2,15 +2,14 @@
"""Blue Archive Gacha Simulator — in-game-accurate recruitment with live banners.""" """Blue Archive Gacha Simulator — in-game-accurate recruitment with live banners."""
###################################################################### ######################################################################
import asyncio import asyncio
import json
import logging import logging
import random
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import discord import discord
from discord.ext import commands from discord.ext import commands
from . import db as gacha_db
from .data import ( from .data import (
db, db,
fetch_banners, fetch_banners,
@@ -24,40 +23,35 @@ from .gacha_renderer import render_pull
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
STATE_FILE = Path("data/ba_gacha_state.json")
SPARK_TARGET = 200 SPARK_TARGET = 200
_state_lock = asyncio.Lock() BANNER_FILE = Path("data/ba_banner_state.json")
def _load_state() -> dict: def _load_banner_state() -> dict:
"""Load per-user gacha state from disk.""" """Which banner each user has picked. Lightweight JSON, separate from SQLite."""
if STATE_FILE.exists(): if BANNER_FILE.exists():
with open(STATE_FILE, "r") as f: import json
with open(BANNER_FILE, "r") as f:
return json.load(f) return json.load(f)
return {} return {}
def _save_state(state: dict) -> None: def _save_banner_state(state: dict) -> None:
"""Persist per-user gacha state to disk.""" import json
STATE_FILE.parent.mkdir(parents=True, exist_ok=True) BANNER_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(STATE_FILE, "w") as f: with open(BANNER_FILE, "w") as f:
json.dump(state, f, indent=2) json.dump(state, f, indent=2)
async def _get_user_state(user_id: int) -> dict: def _get_banner_id(user_id: int) -> Optional[str]:
async with _state_lock: state = _load_banner_state()
state = _load_state() return state.get(str(user_id))
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: def _set_banner_id(user_id: int, banner_id: Optional[str]) -> None:
async with _state_lock: state = _load_banner_state()
state = _load_state() state[str(user_id)] = banner_id
state[str(user_id)] = user_state _save_banner_state(state)
_save_state(state)
class BlueArchiveGacha(commands.Cog): class BlueArchiveGacha(commands.Cog):
@@ -71,10 +65,10 @@ class BlueArchiveGacha(commands.Cog):
# ── Lifecycle ─────────────────────────────────────────────────────── # ── Lifecycle ───────────────────────────────────────────────────────
async def cog_load(self) -> None: 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() db.load()
gacha_db.init_db()
await self._refresh_banners() await self._refresh_banners()
# Background refresh every 6 hours
self._refresh_task = self.bot.loop.create_task(self._periodic_refresh()) self._refresh_task = self.bot.loop.create_task(self._periodic_refresh())
async def cog_unload(self) -> None: async def cog_unload(self) -> None:
@@ -150,9 +144,7 @@ class BlueArchiveGacha(commands.Cog):
return return
if choice == "regular": if choice == "regular":
user_state = await _get_user_state(ctx.author.id) _set_banner_id(ctx.author.id, "regular")
user_state["banner_id"] = "regular"
await _set_user_state(ctx.author.id, user_state)
await ctx.reply( await ctx.reply(
"Active banner set to **Regular Recruitment** (permanent pool, no rate-up).\n" "Active banner set to **Regular Recruitment** (permanent pool, no rate-up).\n"
"Use `!pull` to recruit!" "Use `!pull` to recruit!"
@@ -180,15 +172,15 @@ class BlueArchiveGacha(commands.Cog):
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool" rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
gtype = banner.get("gachaType", "PickupGacha") gtype = banner.get("gachaType", "PickupGacha")
user_state = await _get_user_state(ctx.author.id) _set_banner_id(ctx.author.id, str(banner["id"]))
user_state["banner_id"] = banner["id"]
user_state["spark"] = 0 # reset spark on banner switch # Show current spark for this banner
await _set_user_state(ctx.author.id, user_state) spark = gacha_db.get_spark(ctx.author.id, str(banner["id"]))
await ctx.reply( await ctx.reply(
f"Active banner: **{gtype}**\n" f"Active banner: **{gtype}**\n"
f"Rate-up: {rateups}\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") @commands.command(name="pull")
@@ -204,8 +196,8 @@ class BlueArchiveGacha(commands.Cog):
else: else:
count = 10 count = 10
user_state = await _get_user_state(ctx.author.id) user_id = ctx.author.id
banner_id = user_state.get("banner_id") banner_id = _get_banner_id(user_id)
# Determine banner info # Determine banner info
try: try:
@@ -255,16 +247,19 @@ class BlueArchiveGacha(commands.Cog):
pulls.append(student) pulls.append(student)
# Update spark # Save pulls to inventory (eligma for dupes) and update spark
user_state["spark"] = user_state.get("spark", 0) + count user_id = ctx.author.id
# Trim history to last 50 spark_banner = str(banner_id) if banner_id else "regular"
history = user_state.get("history", []) spark = gacha_db.add_spark(user_id, spark_banner, count)
history.extend(p["Id"] for p in pulls)
user_state["history"] = history[-50:] eligma_earned = 0
await _set_user_state(ctx.author.id, user_state) 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 # Render result image
spark = user_state["spark"]
try: try:
async with ctx.typing(): async with ctx.typing():
img_bytes = await render_pull(pulls, banner_name, spark) img_bytes = await render_pull(pulls, banner_name, spark)
@@ -277,21 +272,24 @@ class BlueArchiveGacha(commands.Cog):
await ctx.reply(f"Render failed: {e}") await ctx.reply(f"Render failed: {e}")
return return
# Send image only # Send image with spark+eligma caption
try: try:
file = discord.File(img_bytes, filename="gacha_result.png") file = discord.File(img_bytes, filename="gacha_result.png")
try: try:
await teaser.delete() await teaser.delete()
except (discord.NotFound, discord.Forbidden): except (discord.NotFound, discord.Forbidden):
pass pass
await ctx.reply(file=file) await ctx.reply(
f"Recruitment Points: {spark}/{SPARK_TARGET}{dupe_msg}",
file=file,
)
except Exception as e: except Exception as e:
log.exception("Failed to send result image") log.exception("Failed to send result image")
await ctx.reply(f"Failed to send result: {e}") await ctx.reply(f"Failed to send result: {e}")
@commands.command(name="spark") @commands.command(name="spark")
async def spark(self, ctx: commands.Context, *, character_name: str = "") -> None: 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 <character name> Usage: !spark <character name>
""" """
@@ -299,12 +297,14 @@ class BlueArchiveGacha(commands.Cog):
await ctx.reply("Usage: `!spark <character name>` — e.g., `!spark Aru`") await ctx.reply("Usage: `!spark <character name>` — e.g., `!spark Aru`")
return return
user_state = await _get_user_state(ctx.author.id) user_id = ctx.author.id
spark = user_state.get("spark", 0) banner_id = str(_get_banner_id(user_id) or "regular")
spark = gacha_db.get_spark(user_id, banner_id)
if spark < SPARK_TARGET: if spark < SPARK_TARGET:
await ctx.reply( 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 return
@@ -314,22 +314,25 @@ class BlueArchiveGacha(commands.Cog):
await ctx.reply(f"Character **{character_name}** not found in the database.") await ctx.reply(f"Character **{character_name}** not found in the database.")
return return
# Deduct spark # Deduct spark and add student to inventory
user_state["spark"] = spark - SPARK_TARGET if not gacha_db.spend_spark(user_id, banner_id):
await _set_user_state(ctx.author.id, user_state) 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( await ctx.reply(
f"**Spark!** You claimed **{student['Name']}** ({student['StarGrade']}*, {student.get('School', 'Unknown')})!\n" f"**Spark!** You claimed **{student['Name']}** "
f"Remaining recruitment points: {user_state['spark']}" f"({student['StarGrade']}*, {student.get('School', 'Unknown')})!\n"
f"Remaining Recruitment Points: {remaining}"
) )
@gacha.command(name="info") @gacha.command(name="info")
async def gacha_info(self, ctx: commands.Context) -> None: async def gacha_info(self, ctx: commands.Context) -> None:
"""Show your current gacha state — active banner, spark count, and recent pulls.""" """Show your active banner, spark count, and collection summary."""
user_state = await _get_user_state(ctx.author.id) user_id = ctx.author.id
banner_id = user_state.get("banner_id", "regular") banner_id = str(_get_banner_id(user_id) or "regular")
spark = user_state.get("spark", 0) spark = gacha_db.get_spark(user_id, banner_id)
history = user_state.get("history", [])
# Banner name # Banner name
banner_name = "Regular Recruitment" banner_name = "Regular Recruitment"
@@ -356,11 +359,13 @@ class BlueArchiveGacha(commands.Cog):
name="Recruitment Points", value=f"{spark}/{SPARK_TARGET}", inline=True name="Recruitment Points", value=f"{spark}/{SPARK_TARGET}", inline=True
) )
embed.add_field( embed.add_field(
name="Total Pulls", value=str(len(history)), inline=True name="Eligma", value=str(gacha_db.get_eligma(user_id)), inline=True
) )
if recent: stats = gacha_db.get_inventory_stats(user_id)
embed.add_field( embed.add_field(
name="Recent Pulls", value="\n".join(recent) or "None", inline=False name="Collection",
value=f"{stats['unique']} unique students from {stats['total_pulls']} total pulls",
inline=False,
) )
await ctx.reply(embed=embed) await ctx.reply(embed=embed)
+156
View File
@@ -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))
+1
View File
@@ -69,6 +69,7 @@ async def main():
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') await bot.load_extension('blue_archive.gacha')
await bot.load_extension('blue_archive.inventory')
try: try:
await bot.start(os.getenv('DISCORD_TOKEN')) await bot.start(os.getenv('DISCORD_TOKEN'))