""" ClickHouse database operations. """ import json import logging import threading import time import clickhouse_connect from . import config log = logging.getLogger(__name__) _client = None _lock = threading.Lock() def get_client(): global _client if _client is None: for attempt in range(30): try: _client = clickhouse_connect.get_client( host=config.CLICKHOUSE_HOST, port=config.CLICKHOUSE_PORT, ) _client.ping() log.info("Connected to ClickHouse at %s:%s", config.CLICKHOUSE_HOST, config.CLICKHOUSE_PORT) return _client except Exception as e: log.warning("ClickHouse not ready (attempt %d): %s", attempt + 1, e) time.sleep(2) raise RuntimeError("Could not connect to ClickHouse") return _client def _with_lock(fn): """Decorator to serialize all ClickHouse operations.""" def wrapper(*args, **kwargs): with _lock: return fn(*args, **kwargs) wrapper.__name__ = fn.__name__ return wrapper @_with_lock def insert_game(game: dict): """Insert a completed round into the games table.""" client = get_client() client.insert("games", [[ game["game_no"], game["winner"], game["total_pot"], game.get("bet_a", 0), game.get("bet_b", 0), game.get("bet_c", 0), game.get("hand_a", ""), game.get("hand_b", ""), game.get("hand_c", ""), game.get("hand_type_a", 0), game.get("hand_type_b", 0), game.get("hand_type_c", 0), game.get("cards_json", ""), game.get("duration_s", 0), ]], column_names=[ "game_no", "winner", "total_pot", "bet_a", "bet_b", "bet_c", "hand_a", "hand_b", "hand_c", "hand_type_a", "hand_type_b", "hand_type_c", "cards_json", "duration_s", ], ) @_with_lock def insert_bet(bet: dict): """Insert an individual user bet into the bets table.""" client = get_client() client.insert("bets", [[ bet["game_no"], bet["user_id"], bet["chair"], bet["bet_amount"], bet["total_bet"], ]], column_names=["game_no", "user_id", "chair", "bet_amount", "total_bet"], ) @_with_lock def upsert_user(user: dict): """Insert/update a user profile.""" client = get_client() client.insert("users", [[ user["user_id"], user.get("nick_name", ""), user.get("rich_level", 0), user.get("actor_level", 0), user.get("gender", 0), user.get("consume_total", 0), user.get("earn_total", 0), user.get("is_actor", 0), user.get("portrait", ""), ]], column_names=[ "user_id", "nick_name", "rich_level", "actor_level", "gender", "consume_total", "earn_total", "is_actor", "portrait", ], ) @_with_lock def get_recent_games(n: int = 50) -> list[dict]: """Get last N completed games.""" client = get_client() result = client.query( "SELECT game_no, winner, total_pot, bet_a, bet_b, bet_c, " "hand_a, hand_b, hand_c, hand_type_a, hand_type_b, hand_type_c, " "cards_json, duration_s, created_at " "FROM games ORDER BY game_no DESC LIMIT {n:UInt32}", parameters={"n": n}, ) games = [] for row in result.result_rows: games.append({ "game_no": row[0], "winner": row[1], "total_pot": row[2], "bet_a": row[3], "bet_b": row[4], "bet_c": row[5], "hand_a": row[6], "hand_b": row[7], "hand_c": row[8], "hand_type_a": row[9], "hand_type_b": row[10], "hand_type_c": row[11], "cards_json": row[12], "duration_s": row[13], "created_at": str(row[14]), }) return games @_with_lock def get_leaderboard(n: int = 10) -> list[dict]: """ Get top N users by P&L. P&L = sum of winning bets * 1.9 - sum of losing bets. With 2.9x fixed payout: win → +1.9x bet, loss → -1.0x bet. """ client = get_client() result = client.query( """ SELECT b.user_id, any(u.nick_name) AS nick_name, count() AS total_bets, countIf(b.chair = g.winner) AS wins, countIf(b.chair != g.winner) AS losses, toInt64(sumIf(b.bet_amount, b.chair = g.winner) * 1.9 - sumIf(b.bet_amount, b.chair != g.winner)) AS pnl, sum(b.bet_amount) AS total_wagered FROM bets b JOIN games g ON b.game_no = g.game_no LEFT JOIN users u ON b.user_id = u.user_id GROUP BY b.user_id HAVING total_bets >= 3 ORDER BY pnl DESC LIMIT {n:UInt32} """, parameters={"n": n}, ) leaders = [] for row in result.result_rows: leaders.append({ "user_id": row[0], "nick_name": row[1] or str(row[0]), "total_bets": row[2], "wins": row[3], "losses": row[4], "pnl": row[5], "total_wagered": row[6], }) return leaders @_with_lock def get_win_distribution() -> dict: """Get win counts per chair + bet rank distribution.""" client = get_client() result = client.query( "SELECT winner, count() AS cnt FROM games GROUP BY winner ORDER BY winner" ) dist = {"A": 0, "B": 0, "C": 0} for row in result.result_rows: chair = config.CHAIRS.get(row[0], "?") if chair in dist: dist[chair] = row[1] # Bet rank distribution: how often the winning chair had high/mid/low bet rank_result = client.query( """ SELECT countIf(winner_bet >= greatest(bet_a, bet_b, bet_c)) AS high, countIf(winner_bet > least(bet_a, bet_b, bet_c) AND winner_bet < greatest(bet_a, bet_b, bet_c)) AS mid, countIf(winner_bet <= least(bet_a, bet_b, bet_c)) AS low FROM ( SELECT bet_a, bet_b, bet_c, multiIf(winner = 1, bet_a, winner = 2, bet_b, bet_c) AS winner_bet FROM games WHERE bet_a + bet_b + bet_c > 0 ) """ ) bet_rank = {"high": 0, "mid": 0, "low": 0} if rank_result.result_rows: row = rank_result.result_rows[0] bet_rank = {"high": row[0], "mid": row[1], "low": row[2]} return {"chairs": dist, "bet_rank": bet_rank} @_with_lock def get_user_name(user_id: int) -> str | None: """Lookup user nickname from cache.""" client = get_client() result = client.query( "SELECT nick_name FROM users WHERE user_id = {uid:UInt64} LIMIT 1", parameters={"uid": user_id}, ) if result.result_rows: return result.result_rows[0][0] or None return None @_with_lock def get_user_detail(user_id: int) -> dict | None: """Get full user profile + session betting stats.""" client = get_client() # Profile result = client.query( "SELECT user_id, nick_name, rich_level, actor_level, gender, " "consume_total, earn_total, is_actor, portrait " "FROM users WHERE user_id = {uid:UInt64} LIMIT 1", parameters={"uid": user_id}, ) if not result.result_rows: return None row = result.result_rows[0] profile = { "user_id": row[0], "nick_name": row[1], "rich_level": row[2], "actor_level": row[3], "gender": row[4], "consume_total": row[5], "earn_total": row[6], "is_actor": row[7], "portrait": row[8], } # Session betting stats stats_result = client.query( """ SELECT count() AS total_bets, sum(b.bet_amount) AS total_wagered, countDistinct(b.game_no) AS rounds_played, countIf(b.chair = g.winner) AS wins, countIf(b.chair != g.winner) AS losses, toInt64(sumIf(b.bet_amount, b.chair = g.winner) * 1.9 - sumIf(b.bet_amount, b.chair != g.winner)) AS pnl FROM bets b LEFT JOIN games g ON b.game_no = g.game_no WHERE b.user_id = {uid:UInt64} """, parameters={"uid": user_id}, ) if stats_result.result_rows: sr = stats_result.result_rows[0] profile["total_bets"] = sr[0] profile["total_wagered"] = sr[1] profile["rounds_played"] = sr[2] profile["wins"] = sr[3] profile["losses"] = sr[4] profile["pnl"] = sr[5] # Recent bets bets_result = client.query( """ SELECT b.game_no, b.chair, b.bet_amount, b.total_bet, g.winner, b.created_at FROM bets b LEFT JOIN games g ON b.game_no = g.game_no WHERE b.user_id = {uid:UInt64} ORDER BY b.created_at DESC LIMIT 20 """, parameters={"uid": user_id}, ) profile["recent_bets"] = [] for br in bets_result.result_rows: profile["recent_bets"].append({ "game_no": br[0], "chair": br[1], "chair_name": config.CHAIRS.get(br[1], "?"), "bet_amount": br[2], "total_bet": br[3], "winner": br[4], "won": br[1] == br[4] if br[4] else None, "created_at": str(br[5]), }) return profile @_with_lock def get_biggest_winner() -> dict | None: """Get the single biggest winner by P&L this session.""" client = get_client() result = client.query( """ SELECT b.user_id, any(u.nick_name) AS nick_name, any(u.portrait) AS portrait, any(u.rich_level) AS rich_level, count() AS total_bets, countIf(b.chair = g.winner) AS wins, toInt64(sumIf(b.bet_amount, b.chair = g.winner) * 1.9 - sumIf(b.bet_amount, b.chair != g.winner)) AS pnl, sum(b.bet_amount) AS total_wagered FROM bets b JOIN games g ON b.game_no = g.game_no LEFT JOIN users u ON b.user_id = u.user_id GROUP BY b.user_id HAVING total_bets >= 3 ORDER BY pnl DESC LIMIT 1 """ ) if result.result_rows: row = result.result_rows[0] return { "user_id": row[0], "nick_name": row[1] or str(row[0]), "portrait": row[2] or "", "rich_level": row[3], "total_bets": row[4], "wins": row[5], "pnl": row[6], "total_wagered": row[7], } return None @_with_lock def get_analytics(period: str = "all") -> dict: """Get all analytics data for a given time period.""" client = get_client() intervals = { "1h": "now() - INTERVAL 1 HOUR", "6h": "now() - INTERVAL 6 HOUR", "24h": "now() - INTERVAL 24 HOUR", "7d": "now() - INTERVAL 7 DAY", } cutoff_expr = intervals.get(period) game_where = f"WHERE created_at >= {cutoff_expr}" if cutoff_expr else "" bet_where = f"WHERE b.created_at >= {cutoff_expr}" if cutoff_expr else "" # 1. Summary stats summary_result = client.query( f"SELECT count(), sum(total_pot), toInt64(avg(total_pot)) FROM games {game_where}" ) sr = summary_result.result_rows[0] if summary_result.result_rows else (0, 0, 0) # Bet counts bet_summary = client.query( f"SELECT count(), countDistinct(user_id) FROM bets {'WHERE created_at >= ' + cutoff_expr if cutoff_expr else ''}" ) bs = bet_summary.result_rows[0] if bet_summary.result_rows else (0, 0) summary = { "total_games": sr[0], "total_volume": int(sr[1] or 0), "avg_pot": int(sr[2] or 0), "total_bets_placed": bs[0], "unique_bettors": bs[1], } # 2. Win distribution (chairs + bet rank) dist_result = client.query( f"SELECT winner, count() AS cnt FROM games {game_where} GROUP BY winner ORDER BY winner" ) chairs_dist = {"A": 0, "B": 0, "C": 0} for row in dist_result.result_rows: chair = config.CHAIRS.get(row[0], "?") if chair in chairs_dist: chairs_dist[chair] = row[1] rank_result = client.query( f""" SELECT countIf(winner_bet >= greatest(bet_a, bet_b, bet_c)) AS high, countIf(winner_bet > least(bet_a, bet_b, bet_c) AND winner_bet < greatest(bet_a, bet_b, bet_c)) AS mid, countIf(winner_bet <= least(bet_a, bet_b, bet_c)) AS low FROM ( SELECT bet_a, bet_b, bet_c, multiIf(winner = 1, bet_a, winner = 2, bet_b, bet_c) AS winner_bet FROM games {game_where + ' AND' if game_where else 'WHERE'} bet_a + bet_b + bet_c > 0 ) """ ) bet_rank = {"high": 0, "mid": 0, "low": 0} if rank_result.result_rows: rr = rank_result.result_rows[0] bet_rank = {"high": rr[0], "mid": rr[1], "low": rr[2]} win_distribution = {"chairs": chairs_dist, "bet_rank": bet_rank} # 3. Hand type distribution (winning hand types) hand_type_result = client.query( f""" SELECT hand_type, count() AS cnt FROM ( SELECT multiIf(winner = 1, hand_type_a, winner = 2, hand_type_b, hand_type_c) AS hand_type FROM games {game_where} ) WHERE hand_type > 0 GROUP BY hand_type ORDER BY hand_type """ ) hand_type_distribution = {} for row in hand_type_result.result_rows: type_name = config.HAND_TYPES.get(row[0], f"Type {row[0]}") hand_type_distribution[type_name] = row[1] # 4. Leaderboard leaderboard_result = client.query( f""" SELECT b.user_id, any(u.nick_name) AS nick_name, count() AS total_bets, countIf(b.chair = g.winner) AS wins, countIf(b.chair != g.winner) AS losses, toInt64(sumIf(b.bet_amount, b.chair = g.winner) * 1.9 - sumIf(b.bet_amount, b.chair != g.winner)) AS pnl, sum(b.bet_amount) AS total_wagered FROM bets b JOIN games g ON b.game_no = g.game_no LEFT JOIN users u ON b.user_id = u.user_id {bet_where} GROUP BY b.user_id HAVING total_bets >= 3 ORDER BY pnl DESC LIMIT 20 """ ) leaderboard = [] for row in leaderboard_result.result_rows: leaderboard.append({ "user_id": row[0], "nick_name": row[1] or str(row[0]), "total_bets": row[2], "wins": row[3], "losses": row[4], "pnl": row[5], "total_wagered": row[6], }) # 5. Hourly volume hourly_result = client.query( f""" SELECT toStartOfHour(created_at) AS hour, count() AS games, sum(total_pot) AS volume FROM games {game_where} GROUP BY hour ORDER BY hour """ ) hourly_volume = [] for row in hourly_result.result_rows: hourly_volume.append({ "hour": str(row[0]), "games": row[1], "volume": int(row[2] or 0), }) # 6. Games list games_result = client.query( f""" SELECT game_no, winner, total_pot, bet_a, bet_b, bet_c, hand_a, hand_b, hand_c, hand_type_a, hand_type_b, hand_type_c, cards_json, duration_s, created_at FROM games {game_where} ORDER BY game_no DESC LIMIT 200 """ ) games = [] for row in games_result.result_rows: games.append({ "game_no": row[0], "winner": row[1], "total_pot": row[2], "bet_a": row[3], "bet_b": row[4], "bet_c": row[5], "hand_a": row[6], "hand_b": row[7], "hand_c": row[8], "hand_type_a": row[9], "hand_type_b": row[10], "hand_type_c": row[11], "cards_json": row[12], "duration_s": row[13], "created_at": str(row[14]), }) return { "summary": summary, "win_distribution": win_distribution, "hand_type_distribution": hand_type_distribution, "leaderboard": leaderboard, "hourly_volume": hourly_volume, "games": games, } @_with_lock def get_hot_cold_players(n: int = 5) -> dict: """ Get players with highest and lowest P&L over their last 10 bets. Returns {"hot": [...], "cold": [...]}. """ client = get_client() sql = """ WITH ranked AS ( SELECT b.user_id, b.game_no, b.chair, b.bet_amount, g.winner, row_number() OVER (PARTITION BY b.user_id ORDER BY b.created_at DESC) AS rn FROM bets b JOIN games g ON b.game_no = g.game_no ), last10 AS ( SELECT * FROM ranked WHERE rn <= 10 ) SELECT l.user_id, any(u.nick_name) AS nick_name, count() AS total_bets, countIf(l.chair = l.winner) AS wins, toInt64(sumIf(l.bet_amount, l.chair = l.winner) * 1.9 - sumIf(l.bet_amount, l.chair != l.winner)) AS pnl FROM last10 l LEFT JOIN users u ON l.user_id = u.user_id GROUP BY l.user_id HAVING total_bets >= 5 ORDER BY pnl DESC """ result = client.query(sql) all_players = [] for row in result.result_rows: all_players.append({ "user_id": row[0], "nick_name": row[1] or str(row[0]), "total_bets": row[2], "wins": row[3], "pnl": row[4], }) hot = [p for p in all_players if p["pnl"] > 0][:n] cold = [p for p in all_players if p["pnl"] < 0][-n:] cold.reverse() # most negative first return {"hot": hot, "cold": cold}