1
0
forked from sass/tipibot

More bug fixes

This commit is contained in:
Rene Arumetsa
2026-05-03 15:11:32 +03:00
parent d65173fbe9
commit 07e7f5e0b2
9 changed files with 295 additions and 90 deletions

View File

@@ -1306,12 +1306,32 @@ async def do_rob(robber_id: int, target_id: int) -> dict:
pct = random.uniform(0.10, 0.25)
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
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["lifetime_earned"] = robber.get("lifetime_earned", 0) + stolen
robber["biggest_win"] = max(robber.get("biggest_win", 0), stolen)
robber["peak_balance"] = max(robber.get("peak_balance", 0), robber["balance"])
await _commit(robber_id, robber)
await _commit(target_id, target)
robber["lifetime_earned"] = prev_lifetime_earned + stolen
robber["biggest_win"] = max(prev_biggest_win, stolen)
robber["peak_balance"] = max(prev_peak_balance, robber["balance"])
try:
await _commit(robber_id, robber)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
try:
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"])
return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot}
else:
@@ -1400,6 +1420,66 @@ async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
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
# ---------------------------------------------------------------------------
@@ -1505,8 +1585,23 @@ async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
receiver["balance"] += amount
giver["total_given"] = giver.get("total_given", 0) + amount
receiver["total_received"] = receiver.get("total_received", 0) + amount
await _commit(giver_id, giver)
await _commit(receiver_id, receiver)
try:
await _commit(giver_id, giver)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
try:
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"])
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:
"""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()
payout_each = 0
failed_users: list[int] = []
if success and HOUSE_ID is not None:
house = await get_user(HOUSE_ID)
try:
house = await get_user(HOUSE_ID)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
pct = random.uniform(0.20, 0.55)
total = max(300, int(house["balance"] * pct))
payout_each = total // len(user_ids)
house["balance"] = max(0, house["balance"] - total)
await _commit(HOUSE_ID, house)
try:
await _commit(HOUSE_ID, house)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
_txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"])
for uid in user_ids:
user = await get_user(uid)
try:
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["heists_joined"] = user.get("heists_joined", 0) + 1
fine_credited = False
if success:
user["balance"] += payout_each
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
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
if fine > 0:
await _credit_house(fine)
await _commit(uid, user)
try:
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)
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,
)

View File

@@ -126,12 +126,12 @@ async def sync_member(
# --- Backfill User ID if missing ---
raw_id = str(row.get("User ID", "")).strip()
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 ---
sheet_username = str(row.get("Discord", "")).strip()
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) ---
nimi = str(row.get("Nimi", "")).strip()

View File

@@ -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
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)
def refresh() -> list[dict]:
"""Pull all rows from the sheet into the in-memory cache.
Returns the cache (list of dicts keyed by header names).
"""
def _refresh_sync() -> list[dict]:
global _cache
ws = _get_worksheet()
_ensure_headers(ws)
@@ -83,6 +87,11 @@ def refresh() -> list[dict]:
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]:
"""Return the current in-memory cache without re-querying."""
return _cache
@@ -122,16 +131,12 @@ def _row_index_for_member(discord_id: int | None = None, username: str | None =
return None
def update_cell_for_member(
def _update_cell_for_member_sync(
discord_id: int | None,
username: str | None,
column_name: str,
value: str,
) -> bool:
"""Write a value to a specific column for a member row.
Returns True if the write succeeded.
"""
ws = _worksheet or _get_worksheet()
row_idx = _row_index_for_member(discord_id=discord_id, username=username)
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),
value_input_option="USER_ENTERED")
# Keep cache in sync
cache_idx = row_idx - 3
if 0 <= cache_idx < len(_cache):
_cache[cache_idx][column_name] = value
@@ -153,8 +157,19 @@ def update_cell_for_member(
return True
def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
"""Batch-write 'Discordis synced?' for multiple members in a single API call."""
async def update_cell_for_member(
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()
col_idx = EXPECTED_HEADERS.index("Discordis synced?") + 1
cells = []
@@ -170,9 +185,14 @@ def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
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."""
return update_cell_for_member(
return await update_cell_for_member(
discord_id=None,
username=username,
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)."""
return update_cell_for_member(
return await update_cell_for_member(
discord_id=discord_id,
username=None,
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)."""
return update_cell_for_member(
return await update_cell_for_member(
discord_id=discord_id,
username=None,
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:
"""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.
"""
def _add_new_member_row_sync(username: str, discord_id: int) -> None:
ws = _worksheet or _get_worksheet()
row = [""] * len(EXPECTED_HEADERS)
row[EXPECTED_HEADERS.index("Discord")] = username
row[EXPECTED_HEADERS.index("User ID")] = str(discord_id)
row[EXPECTED_HEADERS.index("Discordis synced?")] = "FALSE"
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)}
_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)