forked from sass/tipibot
More bug fixes
This commit is contained in:
2
bot.py
2
bot.py
@@ -386,7 +386,7 @@ async def on_ready():
|
|||||||
# Pull sheet data into cache
|
# Pull sheet data into cache
|
||||||
if IS_DEV_PROFILE:
|
if IS_DEV_PROFILE:
|
||||||
try:
|
try:
|
||||||
data = sheets.refresh()
|
data = await sheets.refresh()
|
||||||
log.info("Loaded %d member rows from Google Sheets", len(data))
|
log.info("Loaded %d member rows from Google Sheets", len(data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Failed to load sheet on startup: %s", e)
|
log.error("Failed to load sheet on startup: %s", e)
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ def register_dev_member_commands(
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sheets.refresh()
|
await sheets.refresh()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
||||||
return
|
return
|
||||||
@@ -177,7 +177,7 @@ def register_dev_member_commands(
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = sheets.refresh()
|
data = await sheets.refresh()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
|
||||||
return
|
return
|
||||||
@@ -195,7 +195,7 @@ def register_dev_member_commands(
|
|||||||
guild.members,
|
guild.members,
|
||||||
)
|
)
|
||||||
if guild_member:
|
if guild_member:
|
||||||
sheets.set_user_id(discord_name, guild_member.id)
|
await sheets.set_user_id(discord_name, guild_member.id)
|
||||||
ids_filled += 1
|
ids_filled += 1
|
||||||
|
|
||||||
data = sheets.get_cache()
|
data = sheets.get_cache()
|
||||||
@@ -243,7 +243,7 @@ def register_dev_member_commands(
|
|||||||
|
|
||||||
if sync_updates:
|
if sync_updates:
|
||||||
try:
|
try:
|
||||||
sheets.batch_set_synced(sync_updates)
|
await sheets.batch_set_synced(sync_updates)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("/check batch_set_synced failed: %s", e)
|
log.error("/check batch_set_synced failed: %s", e)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async def run_birthday_daily(
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = sheets.refresh()
|
data = await sheets.refresh()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Birthday task: sheet refresh failed: %s", e)
|
log.error("Birthday task: sheet refresh failed: %s", e)
|
||||||
data = sheets.get_cache()
|
data = sheets.get_cache()
|
||||||
@@ -68,13 +68,13 @@ async def handle_member_join(
|
|||||||
log.info("Member joined: %s (ID: %s)", member, member.id)
|
log.info("Member joined: %s (ID: %s)", member, member.id)
|
||||||
|
|
||||||
if not sheets.get_cache():
|
if not sheets.get_cache():
|
||||||
sheets.refresh()
|
await sheets.refresh()
|
||||||
|
|
||||||
result = await sync_member(member, member.guild)
|
result = await sync_member(member, member.guild)
|
||||||
|
|
||||||
if result.not_found:
|
if result.not_found:
|
||||||
try:
|
try:
|
||||||
sheets.add_new_member_row(member.name, member.id)
|
await sheets.add_new_member_row(member.name, member.id)
|
||||||
log.info(
|
log.info(
|
||||||
" → %s not in sheet, added new row (Discord=%s, ID=%s)",
|
" → %s not in sheet, added new row (Discord=%s, ID=%s)",
|
||||||
member,
|
member,
|
||||||
@@ -86,7 +86,7 @@ async def handle_member_join(
|
|||||||
return
|
return
|
||||||
|
|
||||||
log_sync_result(member, result)
|
log_sync_result(member, result)
|
||||||
sheets.set_synced(member.id, result.synced)
|
await sheets.set_synced(member.id, result.synced)
|
||||||
|
|
||||||
if result.birthday_soon and not has_announced_today(member.id):
|
if result.birthday_soon and not has_announced_today(member.id):
|
||||||
await announce_birthday(member, bot)
|
await announce_birthday(member, bot)
|
||||||
|
|||||||
@@ -276,18 +276,16 @@ def register_economy_games_commands(
|
|||||||
bet_line_a = bet_line_b = ""
|
bet_line_a = bet_line_b = ""
|
||||||
if self.bet > 0:
|
if self.bet > 0:
|
||||||
if winner == "a":
|
if winner == "a":
|
||||||
res = await economy.do_give(self.player_b.id, self.player_a.id, self.bet)
|
await economy.do_rps_pvp_payout(self.player_a.id, self.bet)
|
||||||
|
bet_line_a = f"\n+{coin(self.bet)}"
|
||||||
|
bet_line_b = f"\n-{coin(self.bet)}"
|
||||||
elif winner == "b":
|
elif winner == "b":
|
||||||
res = await economy.do_give(self.player_a.id, self.player_b.id, self.bet)
|
await economy.do_rps_pvp_payout(self.player_b.id, self.bet)
|
||||||
|
bet_line_a = f"\n-{coin(self.bet)}"
|
||||||
|
bet_line_b = f"\n+{coin(self.bet)}"
|
||||||
else:
|
else:
|
||||||
res = {"ok": True}
|
await economy.do_rps_pvp_refund(self.player_a.id, self.bet)
|
||||||
|
await economy.do_rps_pvp_refund(self.player_b.id, self.bet)
|
||||||
if self.bet > 0 and winner is not None:
|
|
||||||
if res.get("ok"):
|
|
||||||
bet_line_a = f"\n{'+' if winner == 'a' else '-'}{coin(self.bet)}"
|
|
||||||
bet_line_b = f"\n{'+' if winner == 'b' else '-'}{coin(self.bet)}"
|
|
||||||
else:
|
|
||||||
bet_line_a = bet_line_b = S.RPS_UI["duel_broke"]
|
|
||||||
|
|
||||||
data_a = await economy.get_user(self.player_a.id)
|
data_a = await economy.get_user(self.player_a.id)
|
||||||
data_b = await economy.get_user(self.player_b.id)
|
data_b = await economy.get_user(self.player_b.id)
|
||||||
@@ -375,6 +373,9 @@ def register_economy_games_commands(
|
|||||||
if self.game._resolved:
|
if self.game._resolved:
|
||||||
return
|
return
|
||||||
self.game._resolved = True
|
self.game._resolved = True
|
||||||
|
if self.game.bet > 0:
|
||||||
|
await economy.do_rps_pvp_refund(self.game.player_a.id, self.game.bet)
|
||||||
|
await economy.do_rps_pvp_refund(self.game.player_b.id, self.game.bet)
|
||||||
active_games.discard(self.game.player_a.id)
|
active_games.discard(self.game.player_a.id)
|
||||||
active_games.discard(self.game.player_b.id)
|
active_games.discard(self.game.player_b.id)
|
||||||
for item in self.children:
|
for item in self.children:
|
||||||
@@ -431,13 +432,25 @@ def register_economy_games_commands(
|
|||||||
active_games.add(self.game.player_b.id)
|
active_games.add(self.game.player_b.id)
|
||||||
|
|
||||||
if self.game.bet > 0:
|
if self.game.bet > 0:
|
||||||
data_a = await economy.get_user(self.game.player_a.id)
|
deposit_a = await economy.do_rps_pvp_deposit(self.game.player_a.id, self.game.bet)
|
||||||
data_b = await economy.get_user(self.game.player_b.id)
|
if not deposit_a.get("ok"):
|
||||||
for player, data in ((self.game.player_a, data_a), (self.game.player_b, data_b)):
|
|
||||||
if data["balance"] < self.game.bet:
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=S.TITLE["rps_duel_cancel"],
|
title=S.TITLE["rps_duel_cancel"],
|
||||||
description=S.RPS_UI["duel_insufficient"].format(mention=player.mention),
|
description=S.RPS_UI["duel_insufficient"].format(mention=self.game.player_a.mention),
|
||||||
|
color=0xED4245,
|
||||||
|
)
|
||||||
|
await interaction.response.edit_message(embed=embed, view=None)
|
||||||
|
async with self.game._lock:
|
||||||
|
self.game._resolved = True
|
||||||
|
active_games.discard(self.game.player_a.id)
|
||||||
|
active_games.discard(self.game.player_b.id)
|
||||||
|
return
|
||||||
|
deposit_b = await economy.do_rps_pvp_deposit(self.game.player_b.id, self.game.bet)
|
||||||
|
if not deposit_b.get("ok"):
|
||||||
|
await economy.do_rps_pvp_refund(self.game.player_a.id, self.game.bet)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.TITLE["rps_duel_cancel"],
|
||||||
|
description=S.RPS_UI["duel_insufficient"].format(mention=self.game.player_b.mention),
|
||||||
color=0xED4245,
|
color=0xED4245,
|
||||||
)
|
)
|
||||||
await interaction.response.edit_message(embed=embed, view=None)
|
await interaction.response.edit_message(embed=embed, view=None)
|
||||||
@@ -479,6 +492,9 @@ def register_economy_games_commands(
|
|||||||
if dm_failed:
|
if dm_failed:
|
||||||
async with self.game._lock:
|
async with self.game._lock:
|
||||||
self.game._resolved = True
|
self.game._resolved = True
|
||||||
|
if self.game.bet > 0:
|
||||||
|
await economy.do_rps_pvp_refund(self.game.player_a.id, self.game.bet)
|
||||||
|
await economy.do_rps_pvp_refund(self.game.player_b.id, self.game.bet)
|
||||||
active_games.discard(self.game.player_a.id)
|
active_games.discard(self.game.player_a.id)
|
||||||
active_games.discard(self.game.player_b.id)
|
active_games.discard(self.game.player_b.id)
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
|
|||||||
167
core/economy.py
167
core/economy.py
@@ -1306,12 +1306,32 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
|
|||||||
pct = random.uniform(0.10, 0.25)
|
pct = random.uniform(0.10, 0.25)
|
||||||
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
|
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
|
||||||
target["balance"] -= stolen
|
target["balance"] -= stolen
|
||||||
|
prev_lifetime_earned = robber.get("lifetime_earned", 0)
|
||||||
|
prev_biggest_win = robber.get("biggest_win", 0)
|
||||||
|
prev_peak_balance = robber.get("peak_balance", 0)
|
||||||
robber["balance"] += stolen
|
robber["balance"] += stolen
|
||||||
robber["lifetime_earned"] = robber.get("lifetime_earned", 0) + stolen
|
robber["lifetime_earned"] = prev_lifetime_earned + stolen
|
||||||
robber["biggest_win"] = max(robber.get("biggest_win", 0), stolen)
|
robber["biggest_win"] = max(prev_biggest_win, stolen)
|
||||||
robber["peak_balance"] = max(robber.get("peak_balance", 0), robber["balance"])
|
robber["peak_balance"] = max(prev_peak_balance, robber["balance"])
|
||||||
|
try:
|
||||||
await _commit(robber_id, robber)
|
await _commit(robber_id, robber)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
try:
|
||||||
await _commit(target_id, target)
|
await _commit(target_id, target)
|
||||||
|
except DatabaseError:
|
||||||
|
robber["balance"] -= stolen
|
||||||
|
robber["lifetime_earned"] = prev_lifetime_earned
|
||||||
|
robber["biggest_win"] = prev_biggest_win
|
||||||
|
robber["peak_balance"] = prev_peak_balance
|
||||||
|
try:
|
||||||
|
await _commit(robber_id, robber)
|
||||||
|
except DatabaseError as exc2:
|
||||||
|
_log.critical(
|
||||||
|
"do_rob rollback failed for robber %s after target commit failed: %s",
|
||||||
|
robber_id, exc2,
|
||||||
|
)
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
_txn("ROB_WIN", robber=robber_id, victim=target_id, stolen=f"+{stolen}", jackpot=jackpot, robber_bal=robber["balance"], victim_bal=target["balance"])
|
_txn("ROB_WIN", robber=robber_id, victim=target_id, stolen=f"+{stolen}", jackpot=jackpot, robber_bal=robber["balance"], victim_bal=target["balance"])
|
||||||
return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot}
|
return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot}
|
||||||
else:
|
else:
|
||||||
@@ -1400,6 +1420,66 @@ async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
|
|||||||
return {"ok": True, "balance": user["balance"]}
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /rps PvP escrow (deposit/payout/refund)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict:
|
||||||
|
"""Hold `bet` coins from a player as escrow for a PvP RPS duel."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
if user.get("eco_banned"):
|
||||||
|
return {"ok": False, "reason": "banned"}
|
||||||
|
if jail := _is_jailed(user):
|
||||||
|
return {"ok": False, "reason": "jailed", "remaining": jail}
|
||||||
|
if user["balance"] < bet:
|
||||||
|
return {"ok": False, "reason": "insufficient"}
|
||||||
|
user["balance"] -= bet
|
||||||
|
user["total_wagered"] = user.get("total_wagered", 0) + bet
|
||||||
|
try:
|
||||||
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
_txn("RPS_PVP_DEPOSIT", user=user_id, bet=bet, bal=user["balance"])
|
||||||
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict:
|
||||||
|
"""Credit the duel winner with 2*bet (their stake back + opponent's)."""
|
||||||
|
try:
|
||||||
|
user = await get_user(winner_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
payout = bet * 2
|
||||||
|
user["balance"] = user.get("balance", 0) + payout
|
||||||
|
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
|
||||||
|
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
|
||||||
|
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
|
||||||
|
try:
|
||||||
|
await _commit(winner_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
_txn("RPS_PVP_PAYOUT", user=winner_id, payout=f"+{payout}", bal=user["balance"])
|
||||||
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def do_rps_pvp_refund(user_id: int, bet: int) -> dict:
|
||||||
|
"""Refund a previously escrowed bet (tie / timeout / cancel)."""
|
||||||
|
try:
|
||||||
|
user = await get_user(user_id)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
user["balance"] = user.get("balance", 0) + bet
|
||||||
|
user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet)
|
||||||
|
try:
|
||||||
|
await _commit(user_id, user)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
_txn("RPS_PVP_REFUND", user=user_id, bet=bet, bal=user["balance"])
|
||||||
|
return {"ok": True, "balance": user["balance"]}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /slots
|
# /slots
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1505,8 +1585,23 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
|
|||||||
receiver["balance"] += amount
|
receiver["balance"] += amount
|
||||||
giver["total_given"] = giver.get("total_given", 0) + amount
|
giver["total_given"] = giver.get("total_given", 0) + amount
|
||||||
receiver["total_received"] = receiver.get("total_received", 0) + amount
|
receiver["total_received"] = receiver.get("total_received", 0) + amount
|
||||||
|
try:
|
||||||
await _commit(giver_id, giver)
|
await _commit(giver_id, giver)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
|
try:
|
||||||
await _commit(receiver_id, receiver)
|
await _commit(receiver_id, receiver)
|
||||||
|
except DatabaseError:
|
||||||
|
giver["balance"] += amount
|
||||||
|
giver["total_given"] = max(0, giver.get("total_given", 0) - amount)
|
||||||
|
try:
|
||||||
|
await _commit(giver_id, giver)
|
||||||
|
except DatabaseError as exc2:
|
||||||
|
_log.critical(
|
||||||
|
"do_give rollback failed for giver %s after receiver commit failed: %s",
|
||||||
|
giver_id, exc2,
|
||||||
|
)
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
_txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"])
|
_txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1760,23 +1855,41 @@ async def do_heist_check(user_id: int) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
||||||
"""Apply heist outcome to all participants. On win, steals from house."""
|
"""Apply heist outcome to all participants. On win, steals from house.
|
||||||
|
|
||||||
|
Per-user commit failures attempt to compensate the house so the economy
|
||||||
|
stays balanced. If compensation also fails, a CRITICAL log is emitted.
|
||||||
|
"""
|
||||||
now = _now()
|
now = _now()
|
||||||
payout_each = 0
|
payout_each = 0
|
||||||
|
failed_users: list[int] = []
|
||||||
|
|
||||||
if success and HOUSE_ID is not None:
|
if success and HOUSE_ID is not None:
|
||||||
|
try:
|
||||||
house = await get_user(HOUSE_ID)
|
house = await get_user(HOUSE_ID)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
pct = random.uniform(0.20, 0.55)
|
pct = random.uniform(0.20, 0.55)
|
||||||
total = max(300, int(house["balance"] * pct))
|
total = max(300, int(house["balance"] * pct))
|
||||||
payout_each = total // len(user_ids)
|
payout_each = total // len(user_ids)
|
||||||
house["balance"] = max(0, house["balance"] - total)
|
house["balance"] = max(0, house["balance"] - total)
|
||||||
|
try:
|
||||||
await _commit(HOUSE_ID, house)
|
await _commit(HOUSE_ID, house)
|
||||||
|
except DatabaseError:
|
||||||
|
return {"ok": False, "reason": "db_error"}
|
||||||
_txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"])
|
_txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"])
|
||||||
|
|
||||||
for uid in user_ids:
|
for uid in user_ids:
|
||||||
|
try:
|
||||||
user = await get_user(uid)
|
user = await get_user(uid)
|
||||||
|
except DatabaseError:
|
||||||
|
failed_users.append(uid)
|
||||||
|
if success and payout_each > 0:
|
||||||
|
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||||
|
continue
|
||||||
user["last_heist"] = now.isoformat()
|
user["last_heist"] = now.isoformat()
|
||||||
user["heists_joined"] = user.get("heists_joined", 0) + 1
|
user["heists_joined"] = user.get("heists_joined", 0) + 1
|
||||||
|
fine_credited = False
|
||||||
if success:
|
if success:
|
||||||
user["balance"] += payout_each
|
user["balance"] += payout_each
|
||||||
user["heists_won"] = user.get("heists_won", 0) + 1
|
user["heists_won"] = user.get("heists_won", 0) + 1
|
||||||
@@ -1792,7 +1905,51 @@ async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
|
|||||||
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
|
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
|
||||||
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
|
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
|
||||||
if fine > 0:
|
if fine > 0:
|
||||||
|
try:
|
||||||
await _credit_house(fine)
|
await _credit_house(fine)
|
||||||
|
fine_credited = True
|
||||||
|
except DatabaseError:
|
||||||
|
pass # user commit will still be attempted; if both fail, no economy effect
|
||||||
|
try:
|
||||||
await _commit(uid, user)
|
await _commit(uid, user)
|
||||||
|
except DatabaseError:
|
||||||
|
failed_users.append(uid)
|
||||||
|
if success and payout_each > 0:
|
||||||
|
await _refund_house_safe(payout_each, "heist_win_compensate", uid)
|
||||||
|
elif not success and fine_credited:
|
||||||
|
await _refund_user_safe(HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid)
|
||||||
|
|
||||||
return {"ok": True, "payout_each": payout_each, "success": success}
|
return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users}
|
||||||
|
|
||||||
|
|
||||||
|
async def _refund_house_safe(amount: int, context: str, related_uid: int) -> None:
|
||||||
|
"""Best-effort refund of `amount` to the house. Logs critical if it fails."""
|
||||||
|
if HOUSE_ID is None or amount <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
house = await get_user(HOUSE_ID)
|
||||||
|
house["balance"] = house.get("balance", 0) + amount
|
||||||
|
await _commit(HOUSE_ID, house)
|
||||||
|
except DatabaseError as exc:
|
||||||
|
_log.critical(
|
||||||
|
"House compensation failed (%s, related uid %s, amount %s): %s",
|
||||||
|
context, related_uid, amount, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None:
|
||||||
|
"""Best-effort debit of `amount` from the house (compensates a failed user fine).
|
||||||
|
|
||||||
|
Reads house, subtracts amount, commits. Logs critical if it fails.
|
||||||
|
"""
|
||||||
|
if HOUSE_ID is None or amount <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
house = await get_user(HOUSE_ID)
|
||||||
|
house["balance"] = max(0, house.get("balance", 0) - amount)
|
||||||
|
await _commit(HOUSE_ID, house)
|
||||||
|
except DatabaseError as exc:
|
||||||
|
_log.critical(
|
||||||
|
"House debit compensation failed (%s, related uid %s, amount %s): %s",
|
||||||
|
context, uid, amount, exc,
|
||||||
|
)
|
||||||
|
|||||||
@@ -126,12 +126,12 @@ async def sync_member(
|
|||||||
# --- Backfill User ID if missing ---
|
# --- Backfill User ID if missing ---
|
||||||
raw_id = str(row.get("User ID", "")).strip()
|
raw_id = str(row.get("User ID", "")).strip()
|
||||||
if not raw_id or raw_id == "0":
|
if not raw_id or raw_id == "0":
|
||||||
sheets.set_user_id(member.name, member.id)
|
await sheets.set_user_id(member.name, member.id)
|
||||||
|
|
||||||
# --- Update Discord username in sheet if it changed ---
|
# --- Update Discord username in sheet if it changed ---
|
||||||
sheet_username = str(row.get("Discord", "")).strip()
|
sheet_username = str(row.get("Discord", "")).strip()
|
||||||
if sheet_username.lower() != member.name.lower():
|
if sheet_username.lower() != member.name.lower():
|
||||||
sheets.update_username(member.id, member.name)
|
await sheets.update_username(member.id, member.name)
|
||||||
|
|
||||||
# --- Nickname (Nimi = real name, formatted as first name + last initial) ---
|
# --- Nickname (Nimi = real name, formatted as first name + last initial) ---
|
||||||
nimi = str(row.get("Nimi", "")).strip()
|
nimi = str(row.get("Nimi", "")).strip()
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
"""Google Sheets integration - read/write member data via gspread."""
|
"""Google Sheets integration - read/write member data via gspread.
|
||||||
|
|
||||||
|
Public network-hitting functions are async and delegate the blocking gspread
|
||||||
|
work to `asyncio.to_thread` so the discord.py event loop is not stalled
|
||||||
|
(stalled loops drop gateway heartbeats and can disconnect the bot).
|
||||||
|
Pure-cache helpers (get_cache, find_*) remain sync.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import gspread
|
import gspread
|
||||||
from google.oauth2.service_account import Credentials
|
from google.oauth2.service_account import Credentials
|
||||||
@@ -69,11 +77,7 @@ def _ensure_headers(ws: gspread.Worksheet) -> None:
|
|||||||
ws.update_cell(1, col_idx, header)
|
ws.update_cell(1, col_idx, header)
|
||||||
|
|
||||||
|
|
||||||
def refresh() -> list[dict]:
|
def _refresh_sync() -> list[dict]:
|
||||||
"""Pull all rows from the sheet into the in-memory cache.
|
|
||||||
|
|
||||||
Returns the cache (list of dicts keyed by header names).
|
|
||||||
"""
|
|
||||||
global _cache
|
global _cache
|
||||||
ws = _get_worksheet()
|
ws = _get_worksheet()
|
||||||
_ensure_headers(ws)
|
_ensure_headers(ws)
|
||||||
@@ -83,6 +87,11 @@ def refresh() -> list[dict]:
|
|||||||
return _cache
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh() -> list[dict]:
|
||||||
|
"""Pull all rows from the sheet into the in-memory cache (non-blocking)."""
|
||||||
|
return await asyncio.to_thread(_refresh_sync)
|
||||||
|
|
||||||
|
|
||||||
def get_cache() -> list[dict]:
|
def get_cache() -> list[dict]:
|
||||||
"""Return the current in-memory cache without re-querying."""
|
"""Return the current in-memory cache without re-querying."""
|
||||||
return _cache
|
return _cache
|
||||||
@@ -122,16 +131,12 @@ def _row_index_for_member(discord_id: int | None = None, username: str | None =
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def update_cell_for_member(
|
def _update_cell_for_member_sync(
|
||||||
discord_id: int | None,
|
discord_id: int | None,
|
||||||
username: str | None,
|
username: str | None,
|
||||||
column_name: str,
|
column_name: str,
|
||||||
value: str,
|
value: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Write a value to a specific column for a member row.
|
|
||||||
|
|
||||||
Returns True if the write succeeded.
|
|
||||||
"""
|
|
||||||
ws = _worksheet or _get_worksheet()
|
ws = _worksheet or _get_worksheet()
|
||||||
row_idx = _row_index_for_member(discord_id=discord_id, username=username)
|
row_idx = _row_index_for_member(discord_id=discord_id, username=username)
|
||||||
if row_idx is None:
|
if row_idx is None:
|
||||||
@@ -145,7 +150,6 @@ def update_cell_for_member(
|
|||||||
ws.update([[value]], gspread.utils.rowcol_to_a1(row_idx, col_idx),
|
ws.update([[value]], gspread.utils.rowcol_to_a1(row_idx, col_idx),
|
||||||
value_input_option="USER_ENTERED")
|
value_input_option="USER_ENTERED")
|
||||||
|
|
||||||
# Keep cache in sync
|
|
||||||
cache_idx = row_idx - 3
|
cache_idx = row_idx - 3
|
||||||
if 0 <= cache_idx < len(_cache):
|
if 0 <= cache_idx < len(_cache):
|
||||||
_cache[cache_idx][column_name] = value
|
_cache[cache_idx][column_name] = value
|
||||||
@@ -153,8 +157,19 @@ def update_cell_for_member(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
|
async def update_cell_for_member(
|
||||||
"""Batch-write 'Discordis synced?' for multiple members in a single API call."""
|
discord_id: int | None,
|
||||||
|
username: str | None,
|
||||||
|
column_name: str,
|
||||||
|
value: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Write a value to a specific column for a member row (non-blocking)."""
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_update_cell_for_member_sync, discord_id, username, column_name, value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_set_synced_sync(updates: list[tuple[int, bool]]) -> None:
|
||||||
ws = _worksheet or _get_worksheet()
|
ws = _worksheet or _get_worksheet()
|
||||||
col_idx = EXPECTED_HEADERS.index("Discordis synced?") + 1
|
col_idx = EXPECTED_HEADERS.index("Discordis synced?") + 1
|
||||||
cells = []
|
cells = []
|
||||||
@@ -170,9 +185,14 @@ def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
|
|||||||
ws.update_cells(cells, value_input_option="USER_ENTERED")
|
ws.update_cells(cells, value_input_option="USER_ENTERED")
|
||||||
|
|
||||||
|
|
||||||
def set_user_id(username: str, discord_id: int) -> bool:
|
async def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
|
||||||
|
"""Batch-write 'Discordis synced?' for multiple members (non-blocking)."""
|
||||||
|
await asyncio.to_thread(_batch_set_synced_sync, updates)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_id(username: str, discord_id: int) -> bool:
|
||||||
"""Write a Discord user ID for a row matched by Discord username."""
|
"""Write a Discord user ID for a row matched by Discord username."""
|
||||||
return update_cell_for_member(
|
return await update_cell_for_member(
|
||||||
discord_id=None,
|
discord_id=None,
|
||||||
username=username,
|
username=username,
|
||||||
column_name="User ID",
|
column_name="User ID",
|
||||||
@@ -180,9 +200,9 @@ def set_user_id(username: str, discord_id: int) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_synced(discord_id: int, synced: bool) -> bool:
|
async def set_synced(discord_id: int, synced: bool) -> bool:
|
||||||
"""Mark a member as synced (TRUE) or not (FALSE)."""
|
"""Mark a member as synced (TRUE) or not (FALSE)."""
|
||||||
return update_cell_for_member(
|
return await update_cell_for_member(
|
||||||
discord_id=discord_id,
|
discord_id=discord_id,
|
||||||
username=None,
|
username=None,
|
||||||
column_name="Discordis synced?",
|
column_name="Discordis synced?",
|
||||||
@@ -190,9 +210,9 @@ def set_synced(discord_id: int, synced: bool) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_username(discord_id: int, new_username: str) -> bool:
|
async def update_username(discord_id: int, new_username: str) -> bool:
|
||||||
"""Update the Discord column for a member (keeps sheet in sync with Discord)."""
|
"""Update the Discord column for a member (keeps sheet in sync with Discord)."""
|
||||||
return update_cell_for_member(
|
return await update_cell_for_member(
|
||||||
discord_id=discord_id,
|
discord_id=discord_id,
|
||||||
username=None,
|
username=None,
|
||||||
column_name="Discord",
|
column_name="Discord",
|
||||||
@@ -200,17 +220,17 @@ def update_username(discord_id: int, new_username: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_new_member_row(username: str, discord_id: int) -> None:
|
def _add_new_member_row_sync(username: str, discord_id: int) -> None:
|
||||||
"""Append a new row to the sheet with Discord username and User ID pre-filled.
|
|
||||||
|
|
||||||
All other columns are left empty for manual entry by an admin.
|
|
||||||
"""
|
|
||||||
ws = _worksheet or _get_worksheet()
|
ws = _worksheet or _get_worksheet()
|
||||||
row = [""] * len(EXPECTED_HEADERS)
|
row = [""] * len(EXPECTED_HEADERS)
|
||||||
row[EXPECTED_HEADERS.index("Discord")] = username
|
row[EXPECTED_HEADERS.index("Discord")] = username
|
||||||
row[EXPECTED_HEADERS.index("User ID")] = str(discord_id)
|
row[EXPECTED_HEADERS.index("User ID")] = str(discord_id)
|
||||||
row[EXPECTED_HEADERS.index("Discordis synced?")] = "FALSE"
|
row[EXPECTED_HEADERS.index("Discordis synced?")] = "FALSE"
|
||||||
ws.append_row(row, value_input_option="USER_ENTERED")
|
ws.append_row(row, value_input_option="USER_ENTERED")
|
||||||
# Add to local cache so subsequent find_member() calls work in the same session
|
|
||||||
new_entry = {h: row[i] for i, h in enumerate(EXPECTED_HEADERS)}
|
new_entry = {h: row[i] for i, h in enumerate(EXPECTED_HEADERS)}
|
||||||
_cache.append(new_entry)
|
_cache.append(new_entry)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_new_member_row(username: str, discord_id: int) -> None:
|
||||||
|
"""Append a new row pre-filled with Discord username and User ID (non-blocking)."""
|
||||||
|
await asyncio.to_thread(_add_new_member_row_sync, username, discord_id)
|
||||||
|
|||||||
@@ -8,3 +8,7 @@ Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`).
|
|||||||
- Added `/patchnotes`
|
- Added `/patchnotes`
|
||||||
- Fixed silent swallowing of database write errors — failed saves now show the user an error instead of appearing to succeed
|
- Fixed silent swallowing of database write errors — failed saves now show the user an error instead of appearing to succeed
|
||||||
- Fixed fish-sell bug that let the last fish be duplicated (sold and kept in inventory)
|
- Fixed fish-sell bug that let the last fish be duplicated (sold and kept in inventory)
|
||||||
|
- Fixed RPS PvP duels having no bet escrow — bets are now held when the duel is accepted, paid out to the winner, and refunded on tie / timeout / cancel (previously the loser could spend their balance before the duel resolved and the winner would get nothing)
|
||||||
|
- Fixed multi-party transfers leaving coins in limbo on partial failures — `/give` and `/rob` now roll back the first commit if the second fails; heists try to refund the house when a participant payout fails
|
||||||
|
- Fixed Google Sheets I/O blocking the Discord gateway — sheet reads/writes now run in a worker thread so heartbeats stay alive during slow API calls
|
||||||
|
- Fixed migration script overwriting accumulated PocketBase state on re-run — fields not present in the legacy JSON are now preserved instead of being clobbered with defaults
|
||||||
|
|||||||
@@ -42,10 +42,23 @@ async def main() -> None:
|
|||||||
total = len(raw)
|
total = len(raw)
|
||||||
print(f"Found {total} user(s) in {DATA_FILE}")
|
print(f"Found {total} user(s) in {DATA_FILE}")
|
||||||
|
|
||||||
created = skipped = errors = 0
|
created = updated = errors = 0
|
||||||
|
|
||||||
for uid, user in raw.items():
|
for uid, user in raw.items():
|
||||||
try:
|
try:
|
||||||
|
existing = await pb_client.get_record(uid)
|
||||||
|
if existing:
|
||||||
|
# Merge JSON fields *onto* the existing record so values that have
|
||||||
|
# accumulated in PB (items, daily_streak, reminders, etc.) are not
|
||||||
|
# clobbered by JSON defaults on a re-run. JSON values take
|
||||||
|
# precedence only for keys that are actually present.
|
||||||
|
merged: dict = {k: v for k, v in existing.items() if not k.startswith("_") and k != "id"}
|
||||||
|
merged.update(user)
|
||||||
|
merged["user_id"] = uid
|
||||||
|
await pb_client.update_record(existing["id"], merged)
|
||||||
|
print(f" [UPDATE] {uid}")
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
record = dict(user)
|
record = dict(user)
|
||||||
record["user_id"] = uid
|
record["user_id"] = uid
|
||||||
record.setdefault("balance", 0)
|
record.setdefault("balance", 0)
|
||||||
@@ -55,13 +68,6 @@ async def main() -> None:
|
|||||||
record.setdefault("reminders", ["daily", "work", "beg", "crime", "rob"])
|
record.setdefault("reminders", ["daily", "work", "beg", "crime", "rob"])
|
||||||
record.setdefault("eco_banned", False)
|
record.setdefault("eco_banned", False)
|
||||||
record.setdefault("daily_streak", 0)
|
record.setdefault("daily_streak", 0)
|
||||||
|
|
||||||
existing = await pb_client.get_record(uid)
|
|
||||||
if existing:
|
|
||||||
await pb_client.update_record(existing["id"], record)
|
|
||||||
print(f" [UPDATE] {uid}")
|
|
||||||
skipped += 1 # reuse skipped counter as "updated"
|
|
||||||
else:
|
|
||||||
await pb_client.create_record(record)
|
await pb_client.create_record(record)
|
||||||
print(f" [CREATE] {uid}")
|
print(f" [CREATE] {uid}")
|
||||||
created += 1
|
created += 1
|
||||||
@@ -69,7 +75,9 @@ async def main() -> None:
|
|||||||
print(f" [ERROR] {uid}: {exc}")
|
print(f" [ERROR] {uid}: {exc}")
|
||||||
errors += 1
|
errors += 1
|
||||||
|
|
||||||
print(f"\nDone. Created: {created} Skipped: {skipped} Errors: {errors}")
|
print(f"\nDone. Created: {created} Updated: {updated} Errors: {errors}")
|
||||||
|
if errors:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user