diff --git a/bot.py b/bot.py index 3703330..200e9f5 100644 --- a/bot.py +++ b/bot.py @@ -386,7 +386,7 @@ async def on_ready(): # Pull sheet data into cache if IS_DEV_PROFILE: try: - data = sheets.refresh() + data = await sheets.refresh() log.info("Loaded %d member rows from Google Sheets", len(data)) except Exception as e: log.error("Failed to load sheet on startup: %s", e) diff --git a/commands/dev_member_commands.py b/commands/dev_member_commands.py index 26bd757..9d44b8c 100644 --- a/commands/dev_member_commands.py +++ b/commands/dev_member_commands.py @@ -157,7 +157,7 @@ def register_dev_member_commands( await interaction.response.defer() try: - sheets.refresh() + await sheets.refresh() except Exception as e: await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) return @@ -177,7 +177,7 @@ def register_dev_member_commands( return try: - data = sheets.refresh() + data = await sheets.refresh() except Exception as e: await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) return @@ -195,7 +195,7 @@ def register_dev_member_commands( guild.members, ) 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 data = sheets.get_cache() @@ -243,7 +243,7 @@ def register_dev_member_commands( if sync_updates: try: - sheets.batch_set_synced(sync_updates) + await sheets.batch_set_synced(sync_updates) except Exception as e: log.error("/check batch_set_synced failed: %s", e) diff --git a/commands/dev_member_runtime.py b/commands/dev_member_runtime.py index 92550e1..71b1510 100644 --- a/commands/dev_member_runtime.py +++ b/commands/dev_member_runtime.py @@ -23,7 +23,7 @@ async def run_birthday_daily( return try: - data = sheets.refresh() + data = await sheets.refresh() except Exception as e: log.error("Birthday task: sheet refresh failed: %s", e) data = sheets.get_cache() @@ -68,13 +68,13 @@ async def handle_member_join( log.info("Member joined: %s (ID: %s)", member, member.id) if not sheets.get_cache(): - sheets.refresh() + await sheets.refresh() result = await sync_member(member, member.guild) if result.not_found: try: - sheets.add_new_member_row(member.name, member.id) + await sheets.add_new_member_row(member.name, member.id) log.info( " → %s not in sheet, added new row (Discord=%s, ID=%s)", member, @@ -86,7 +86,7 @@ async def handle_member_join( return 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): await announce_birthday(member, bot) diff --git a/commands/economy_games_commands.py b/commands/economy_games_commands.py index f1f1a41..2f882a0 100644 --- a/commands/economy_games_commands.py +++ b/commands/economy_games_commands.py @@ -276,18 +276,16 @@ def register_economy_games_commands( bet_line_a = bet_line_b = "" if self.bet > 0: 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": - 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: - res = {"ok": True} - - 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"] + await economy.do_rps_pvp_refund(self.player_a.id, self.bet) + await economy.do_rps_pvp_refund(self.player_b.id, self.bet) data_a = await economy.get_user(self.player_a.id) data_b = await economy.get_user(self.player_b.id) @@ -375,6 +373,9 @@ def register_economy_games_commands( if self.game._resolved: return 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_b.id) for item in self.children: @@ -431,21 +432,33 @@ def register_economy_games_commands( active_games.add(self.game.player_b.id) if self.game.bet > 0: - data_a = await economy.get_user(self.game.player_a.id) - data_b = await economy.get_user(self.game.player_b.id) - 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( - title=S.TITLE["rps_duel_cancel"], - description=S.RPS_UI["duel_insufficient"].format(mention=player.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_a = await economy.do_rps_pvp_deposit(self.game.player_a.id, self.game.bet) + if not deposit_a.get("ok"): + embed = discord.Embed( + title=S.TITLE["rps_duel_cancel"], + 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, + ) + 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 bet_str = S.RPS_UI["duel_active_bet"].format(bet=coin(self.game.bet)) if self.game.bet > 0 else "" embed = discord.Embed( @@ -479,6 +492,9 @@ def register_economy_games_commands( if dm_failed: async with self.game._lock: 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_b.id) embed = discord.Embed( diff --git a/core/economy.py b/core/economy.py index 4bfc670..a9a2308 100644 --- a/core/economy.py +++ b/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) 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, + ) diff --git a/core/member_sync.py b/core/member_sync.py index 21da0a0..a9c9766 100644 --- a/core/member_sync.py +++ b/core/member_sync.py @@ -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() diff --git a/core/sheets.py b/core/sheets.py index c94b874..aac6cdc 100644 --- a/core/sheets.py +++ b/core/sheets.py @@ -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) diff --git a/docs/PATCHNOTES.md b/docs/PATCHNOTES.md index 6f87d89..7f85ae0 100644 --- a/docs/PATCHNOTES.md +++ b/docs/PATCHNOTES.md @@ -8,3 +8,7 @@ Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`). - Added `/patchnotes` - 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 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 diff --git a/scripts/migrate_to_pb.py b/scripts/migrate_to_pb.py index 381f646..e53c10b 100644 --- a/scripts/migrate_to_pb.py +++ b/scripts/migrate_to_pb.py @@ -42,26 +42,32 @@ async def main() -> None: total = len(raw) print(f"Found {total} user(s) in {DATA_FILE}") - created = skipped = errors = 0 + created = updated = errors = 0 for uid, user in raw.items(): try: - record = dict(user) - record["user_id"] = uid - record.setdefault("balance", 0) - record.setdefault("exp", 0) - record.setdefault("items", []) - record.setdefault("item_uses", {}) - record.setdefault("reminders", ["daily", "work", "beg", "crime", "rob"]) - record.setdefault("eco_banned", False) - record.setdefault("daily_streak", 0) - existing = await pb_client.get_record(uid) if existing: - await pb_client.update_record(existing["id"], record) + # 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}") - skipped += 1 # reuse skipped counter as "updated" + updated += 1 else: + record = dict(user) + record["user_id"] = uid + record.setdefault("balance", 0) + record.setdefault("exp", 0) + record.setdefault("items", []) + record.setdefault("item_uses", {}) + record.setdefault("reminders", ["daily", "work", "beg", "crime", "rob"]) + record.setdefault("eco_banned", False) + record.setdefault("daily_streak", 0) await pb_client.create_record(record) print(f" [CREATE] {uid}") created += 1 @@ -69,7 +75,9 @@ async def main() -> None: print(f" [ERROR] {uid}: {exc}") 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__":