gacha patch
This commit is contained in:
1 parent
c510a1b63f
commit
3d49c65de3
4 files changed
+97
-48
No files matched your search
@@ -38,6 +38,8 @@ PULL10_RATES = (0.03, 0.97, 0.0)
|
|||||||
BANNER_RATES: dict[str, tuple[float, float, float]] = {
|
BANNER_RATES: dict[str, tuple[float, float, float]] = {
|
||||||
"FesGacha": (0.06, 0.185, 0.755),
|
"FesGacha": (0.06, 0.185, 0.755),
|
||||||
}
|
}
|
||||||
|
# Each rate-up 3★ student has a flat 0.7% per-pull rate (all banner types)
|
||||||
|
RATEUP_RATE = 0.007
|
||||||
|
|
||||||
# ── Spark & Eligma ──────────────────────────────────────────────────────────
|
# ── Spark & Eligma ──────────────────────────────────────────────────────────
|
||||||
SPARK_TARGET = 200
|
SPARK_TARGET = 200
|
||||||
|
|||||||
+63
-29
@@ -20,6 +20,7 @@ from .constants import (
|
|||||||
WIKI_VARIANT_MAP,
|
WIKI_VARIANT_MAP,
|
||||||
BANNER_RATES,
|
BANNER_RATES,
|
||||||
DEFAULT_RATES,
|
DEFAULT_RATES,
|
||||||
|
RATEUP_RATE,
|
||||||
PULL10_RATES,
|
PULL10_RATES,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,16 +205,30 @@ class StudentDB:
|
|||||||
def get(self, student_id: int) -> Optional[dict]:
|
def get(self, student_id: int) -> Optional[dict]:
|
||||||
return self.by_id.get(student_id)
|
return self.by_id.get(student_id)
|
||||||
|
|
||||||
def get_by_name(self, name: str) -> Optional[dict]:
|
def get_by_name(self, name: str, strict: bool = False) -> Optional[dict]:
|
||||||
"""Resolve a student by name with fuzzy fallback.
|
"""Resolve a student by name with fuzzy fallback.
|
||||||
|
|
||||||
Tries: exact match → strip variant suffix → substring match.
|
Tries: exact match → variant alias → strip variant suffix → substring match.
|
||||||
Handles banner-API names like "Maki (Camp)" when only "Maki" exists.
|
With strict=True only exact match and variant alias are tried, so an
|
||||||
|
unknown costume never resolves to its base student (used for rate-ups).
|
||||||
"""
|
"""
|
||||||
key = name.lower().strip()
|
key = name.lower().strip()
|
||||||
if key in self.by_name:
|
if key in self.by_name:
|
||||||
return self.by_name[key]
|
return self.by_name[key]
|
||||||
|
|
||||||
|
# API variant names can differ from SchaleDB ("Hoshino (Armed)" → "Hoshino (Battle)")
|
||||||
|
if "(" in key:
|
||||||
|
base, variant = key.split("(", 1)
|
||||||
|
variant = variant.rstrip(")").strip()
|
||||||
|
for api_v, wiki_v in WIKI_VARIANT_MAP.items():
|
||||||
|
if variant == api_v.lower():
|
||||||
|
alias = f"{base.strip()} ({wiki_v.lower()})"
|
||||||
|
if alias in self.by_name:
|
||||||
|
return self.by_name[alias]
|
||||||
|
|
||||||
|
if strict:
|
||||||
|
return None
|
||||||
|
|
||||||
# "Maki (Camp)" → try "Maki"
|
# "Maki (Camp)" → try "Maki"
|
||||||
if "(" in key:
|
if "(" in key:
|
||||||
base = key.split("(", 1)[0].strip()
|
base = key.split("(", 1)[0].strip()
|
||||||
@@ -227,6 +242,21 @@ class StudentDB:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def resolve_rateups(self, rateup_names: list[str]) -> set[str]:
|
||||||
|
"""Strictly resolve banner rate-up names to roster names (lowercased).
|
||||||
|
|
||||||
|
Returns names rather than IDs because some students have several
|
||||||
|
entries under one name (e.g. both forms of Hoshino (Battle)).
|
||||||
|
"""
|
||||||
|
resolved: set[str] = set()
|
||||||
|
for rn in rateup_names:
|
||||||
|
s = self.get_by_name(rn, strict=True)
|
||||||
|
if s:
|
||||||
|
resolved.add(s["Name"].lower())
|
||||||
|
else:
|
||||||
|
log.warning("Rate-up %r is not in the roster; it cannot be pulled", rn)
|
||||||
|
return resolved
|
||||||
|
|
||||||
def build_pool(self, banner: Optional[dict]) -> dict[int, list[dict]]:
|
def build_pool(self, banner: Optional[dict]) -> dict[int, list[dict]]:
|
||||||
"""Build the pullable character pool for a specific banner.
|
"""Build the pullable character pool for a specific banner.
|
||||||
|
|
||||||
@@ -258,21 +288,27 @@ class StudentDB:
|
|||||||
|
|
||||||
# Add rate-up students (they might already be in the pool, but we need
|
# Add rate-up students (they might already be in the pool, but we need
|
||||||
# to identify them for weighted selection later)
|
# to identify them for weighted selection later)
|
||||||
for name in rateup_names:
|
rateup_keys = self.resolve_rateups(rateup_names)
|
||||||
s = self.get_by_name(name)
|
for s in self.students:
|
||||||
if s and (s.get("IsLimited") or 0) < 2:
|
if s["Name"].lower() in rateup_keys and (s.get("IsLimited") or 0) < 2:
|
||||||
r = s["StarGrade"]
|
r = s["StarGrade"]
|
||||||
if r in pool and s not in pool[r]:
|
if r in pool and s not in pool[r]:
|
||||||
pool[r].append(s)
|
pool[r].append(s)
|
||||||
|
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
def weighted_pick(self, pool: list[dict], rateup_names: list[str], rarity: int) -> dict:
|
def weighted_pick(
|
||||||
"""Pick a random student from a rarity pool with rate-up weighting.
|
self,
|
||||||
|
pool: list[dict],
|
||||||
|
rateup_names: list[str],
|
||||||
|
rarity: int,
|
||||||
|
three_star_rate: float = DEFAULT_RATES[0],
|
||||||
|
) -> dict:
|
||||||
|
"""Pick a random student from a rarity pool with rate-up odds.
|
||||||
|
|
||||||
For 3★: rate-up characters get 3x higher weight than standard ones,
|
For 3★: each rate-up student gets a flat RATEUP_RATE (0.7%) per pull,
|
||||||
producing roughly the same rate-up frequency as the real game's 0.7%
|
like the real game, so on a 3% banner one rate-up is ~23% of 3★ pulls.
|
||||||
per rate-up out of the 3% total.
|
The remaining 3★ share is split evenly over the rest of the pool.
|
||||||
For 1-2★: uniform random (rate-ups don't affect lower rarities).
|
For 1-2★: uniform random (rate-ups don't affect lower rarities).
|
||||||
"""
|
"""
|
||||||
if not pool:
|
if not pool:
|
||||||
@@ -281,20 +317,17 @@ class StudentDB:
|
|||||||
return random.choice(pullable or self.students)
|
return random.choice(pullable or self.students)
|
||||||
|
|
||||||
if rarity == 3 and rateup_names:
|
if rarity == 3 and rateup_names:
|
||||||
# Weighted: rate-up chars get 3x weight each vs normal chars.
|
# Match by name so "Hoshino (Armed)" in API covers every
|
||||||
# Use get_by_name so "Hoshino (Armed)" in API matches "Hoshino" in DB.
|
# "Hoshino (Battle)" entry in DB.
|
||||||
rateup_ids = set()
|
rateup_keys = self.resolve_rateups(rateup_names)
|
||||||
for rn in rateup_names:
|
rateups = [s for s in pool if s["Name"].lower() in rateup_keys]
|
||||||
rs = self.get_by_name(rn)
|
others = [s for s in pool if s["Name"].lower() not in rateup_keys]
|
||||||
if rs:
|
if rateups:
|
||||||
rateup_ids.add(rs["Id"])
|
n_rateups = len({s["Name"].lower() for s in rateups})
|
||||||
weights = []
|
share = min(1.0, RATEUP_RATE * n_rateups / three_star_rate)
|
||||||
for s in pool:
|
if not others or random.random() < share:
|
||||||
if s["Id"] in rateup_ids:
|
return random.choice(rateups)
|
||||||
weights.append(3.0)
|
return random.choice(others)
|
||||||
else:
|
|
||||||
weights.append(1.0)
|
|
||||||
return random.choices(pool, weights=weights, k=1)[0]
|
|
||||||
|
|
||||||
return random.choice(pool)
|
return random.choice(pool)
|
||||||
|
|
||||||
@@ -349,10 +382,11 @@ def get_rates_for_banner(banner: dict) -> tuple[float, float, float]:
|
|||||||
|
|
||||||
# --- Banner fetching ---------------------------------------------------------
|
# --- Banner fetching ---------------------------------------------------------
|
||||||
|
|
||||||
async def fetch_banners(session: Optional[aiohttp.ClientSession] = None) -> dict:
|
async def fetch_banners(session: Optional[aiohttp.ClientSession] = None) -> Optional[dict]:
|
||||||
"""Fetch live banner data from the BlueArchiveAPI.
|
"""Fetch live banner data from the BlueArchiveAPI.
|
||||||
|
|
||||||
Returns {"current": [...], "upcoming": [...], "ended": [...]}.
|
Returns {"current": [...], "upcoming": [...], "ended": [...]}, or None if
|
||||||
|
the fetch failed (so callers can keep their last good data).
|
||||||
Each banner has: id, gachaType, rateups (list of names), startedAt, endedAt.
|
Each banner has: id, gachaType, rateups (list of names), startedAt, endedAt.
|
||||||
"""
|
"""
|
||||||
close_session = session is None
|
close_session = session is None
|
||||||
@@ -369,10 +403,10 @@ async def fetch_banners(session: Optional[aiohttp.ClientSession] = None) -> dict
|
|||||||
return data
|
return data
|
||||||
else:
|
else:
|
||||||
log.warning("Banner API returned status %d", resp.status)
|
log.warning("Banner API returned status %d", resp.status)
|
||||||
return {"current": [], "upcoming": [], "ended": []}
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Failed to fetch banners from API")
|
log.exception("Failed to fetch banners from API")
|
||||||
return {"current": [], "upcoming": [], "ended": []}
|
return None
|
||||||
finally:
|
finally:
|
||||||
if close_session:
|
if close_session:
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|||||||
+30
-19
@@ -77,7 +77,6 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
await asyncio.to_thread(db.load)
|
await asyncio.to_thread(db.load)
|
||||||
await gacha_db.init_db()
|
await gacha_db.init_db()
|
||||||
await self._refresh_banners()
|
await self._refresh_banners()
|
||||||
await db.merge_ennead(self._banner_cache)
|
|
||||||
self._refresh_task = asyncio.create_task(self._periodic_refresh())
|
self._refresh_task = asyncio.create_task(self._periodic_refresh())
|
||||||
|
|
||||||
async def cog_unload(self) -> None:
|
async def cog_unload(self) -> None:
|
||||||
@@ -85,8 +84,14 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
self._refresh_task.cancel()
|
self._refresh_task.cancel()
|
||||||
|
|
||||||
async def _refresh_banners(self) -> None:
|
async def _refresh_banners(self) -> None:
|
||||||
|
"""Refresh banners, then merge any new students they might feature."""
|
||||||
try:
|
try:
|
||||||
self._banner_cache = await fetch_banners()
|
banners = await fetch_banners()
|
||||||
|
if banners is None:
|
||||||
|
log.warning("Banner refresh failed; keeping previous banner data")
|
||||||
|
else:
|
||||||
|
self._banner_cache = banners
|
||||||
|
await db.merge_ennead(self._banner_cache)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Banner refresh failed")
|
log.exception("Banner refresh failed")
|
||||||
finally:
|
finally:
|
||||||
@@ -269,11 +274,16 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
if str(b.get("id")) == banner_id:
|
if str(b.get("id")) == banner_id:
|
||||||
banner = b
|
banner = b
|
||||||
break
|
break
|
||||||
if banner:
|
if not banner:
|
||||||
rateups = ", ".join(banner.get("rateups", [])) or "Standard Pool"
|
await ctx.reply(
|
||||||
gtype = banner.get("gachaType", "PickupGacha")
|
"Your selected banner has ended or is unavailable right now.\n"
|
||||||
banner_name = f"{gtype} — {rateups}"
|
"Pick another with `!gacha pick <n>` (see `!gacha`) or `!gacha pick regular`."
|
||||||
rates = get_rates_for_banner(banner)
|
)
|
||||||
|
return
|
||||||
|
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 — animation GIF, or text fallback
|
# "Opening envelope" teaser — animation GIF, or text fallback
|
||||||
if GACHA_ANIM_PATH.exists():
|
if GACHA_ANIM_PATH.exists():
|
||||||
@@ -306,7 +316,7 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
rarity = roll_rarity(r)
|
rarity = roll_rarity(r)
|
||||||
rarity_pool = pool.get(rarity, [])
|
rarity_pool = pool.get(rarity, [])
|
||||||
if rarity_pool:
|
if rarity_pool:
|
||||||
student = db.weighted_pick(rarity_pool, rateup_names, rarity)
|
student = db.weighted_pick(rarity_pool, rateup_names, rarity, rates[0])
|
||||||
else:
|
else:
|
||||||
student = db.random_by_rarity(rarity) # fallback
|
student = db.random_by_rarity(rarity) # fallback
|
||||||
|
|
||||||
@@ -405,18 +415,19 @@ class BlueArchiveGacha(commands.Cog):
|
|||||||
if str(b.get("id")) == banner_id:
|
if str(b.get("id")) == banner_id:
|
||||||
banner = b
|
banner = b
|
||||||
break
|
break
|
||||||
if banner:
|
if not banner:
|
||||||
rateups = [n.lower() for n in banner.get("rateups", [])]
|
await ctx.reply(
|
||||||
matched = any(
|
"Your selected banner has ended or is unavailable right now, so it can't be sparked."
|
||||||
db.get_by_name(rn) is student for rn in rateups
|
|
||||||
)
|
)
|
||||||
if not matched:
|
return
|
||||||
rateup_str = ", ".join(banner.get("rateups", []))
|
rateups = [n.lower() for n in banner.get("rateups", [])]
|
||||||
await ctx.reply(
|
if student["Name"].lower() not in db.resolve_rateups(rateups):
|
||||||
f"**{student['Name']}** is not a rate-up on this banner.\n"
|
rateup_str = ", ".join(banner.get("rateups", []))
|
||||||
f"Rate-ups: {rateup_str}"
|
await ctx.reply(
|
||||||
)
|
f"**{student['Name']}** is not a rate-up on this banner.\n"
|
||||||
return
|
f"Rate-ups: {rateup_str}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Atomic claim: check points, check ownership, add student, deduct points
|
# Atomic claim: check points, check ownership, add student, deduct points
|
||||||
success, msg = await gacha_db.spark_claim(user_id, banner_id, student["Id"], student["StarGrade"])
|
success, msg = await gacha_db.spark_claim(user_id, banner_id, student["Id"], student["StarGrade"])
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ async def main() -> None:
|
|||||||
chars = await fetch_json(session, CHARACTER_API)
|
chars = await fetch_json(session, CHARACTER_API)
|
||||||
banners = await fetch_banners(session)
|
banners = await fetch_banners(session)
|
||||||
|
|
||||||
|
if banners is None:
|
||||||
|
raise RuntimeError("banner API fetch failed")
|
||||||
if not chars:
|
if not chars:
|
||||||
raise RuntimeError("ennead character API returned an empty list")
|
raise RuntimeError("ennead character API returned an empty list")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user