import logging import asyncio from datetime import datetime from uuid import uuid4 from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, Message, MessageSegment, PrivateMessageEvent from danding_bot.plugins.danding_qqpush.config import Config as QqPushConfig from danding_bot.plugins.danding_qqpush.image_render import ImageRenderer from ..room_store import room_store # use the singleton managed by __init__.py lifecycle hooks from ..points_service import PointsService from ..race_engine import RaceEngine from ..message_service import MessageService from ..models import Room, Horse, Bet, HorseState, RoomState, RaceResult from .. import plugin_config as config logger = logging.getLogger("horse_racing.commands") points_service = PointsService(config) race_engine = RaceEngine(config) message_service = MessageService(config) _race_image_renderer: ImageRenderer | None = None async def _get_user_name(bot: Bot, scope: str, user_id: str) -> str: """Get user display name (group card > nickname > user_id).""" try: if scope.startswith("group_"): group_id = int(scope.split("_", 1)[1]) info = await bot.get_group_member_info(group_id=group_id, user_id=int(user_id)) return info.get("card") or info.get("nickname") or user_id except Exception as exc: logger.debug("获取赛马用户昵称失败 scope=%s user_id=%s error=%s", scope, user_id, exc) return user_id async def _build_name_map(bot: Bot, scope: str, user_ids: list[str]) -> dict[str, str]: """Build a mapping from user_id to display name.""" name_map: dict[str, str] = {} for uid in user_ids: if uid not in name_map: name_map[uid] = await _get_user_name(bot, scope, uid) return name_map def _get_race_image_renderer() -> ImageRenderer: global _race_image_renderer if _race_image_renderer is None: qqpush_config = QqPushConfig() _race_image_renderer = ImageRenderer( width=config.RACE_IMAGE_WIDTH, font_size=config.RACE_IMAGE_FONT_SIZE, padding=config.RACE_IMAGE_PADDING, line_spacing=config.RACE_IMAGE_LINE_SPACING, font_paths=qqpush_config.FontPaths, ) return _race_image_renderer def _build_race_image_message(message: str) -> Message: if message.startswith("比赛开始!"): title = "🏇 赛马开赛" body = message.replace("比赛开始!", "发令枪响,比赛正式开始!", 1) elif message.startswith("比赛结束!"): title = "🏆 赛马结果" body = message else: title = "📣 赛马进度" body = f"🏁 实时播报\n{message}" renderer = _get_race_image_renderer() image_base64 = renderer.render_to_base64(body, title=title) message_obj = Message() message_obj.append(MessageSegment.image(image_base64)) return message_obj def _normalize_horse_name(horse_name: str) -> str: return horse_name.strip().casefold() def _get_horses_in_order(room: Room) -> list[Horse]: return sorted(room.horses.values(), key=lambda horse: horse.index) def _format_horse_label(horse: Horse) -> str: return f"{horse.index:02d}号 {horse.name}" def _find_user_horse(room: Room, user_id: str) -> Horse | None: for horse in _get_horses_in_order(room): if horse.owner_id == user_id: return horse return None def _find_duplicate_horse(room: Room, horse_name: str) -> Horse | None: normalized_name = _normalize_horse_name(horse_name) for horse in room.horses.values(): if _normalize_horse_name(horse.name) == normalized_name: return horse return None def _resolve_horse_selector(room: Room, selector: str) -> Horse | None: selector = selector.strip() if selector.isdigit(): target_index = int(selector) for horse in room.horses.values(): if horse.index == target_index: return horse return None normalized_selector = _normalize_horse_name(selector) for horse in room.horses.values(): if _normalize_horse_name(horse.name) == normalized_selector: return horse return None def _describe_points_delta(delta: int) -> str: if delta >= 300: return "血赚翻倍" if delta >= 150: return "大赚特赚" if delta > 0: return "小有收获" if delta == 0: return "稳住不亏" if delta <= -300: return "倾家荡产" if delta <= -150: return "伤筋动骨" return "略有损失" async def _is_admin_or_owner(bot: Bot, event: Event) -> bool: """Check if the event sender is a group admin or owner.""" if not isinstance(event, GroupMessageEvent): return False try: member_info = await bot.get_group_member_info( group_id=event.group_id, user_id=int(event.get_user_id()), ) return member_info.get("role", "") in ("admin", "owner") except Exception as exc: logger.debug("检查赛马管理员权限失败 user_id=%s error=%s", getattr(event, "user_id", ""), exc) return False def _build_point_changes(room: Room, odds: dict[str, float]) -> tuple[dict[str, int], dict[str, str]]: point_changes: dict[str, int] = {} for horse in room.horses.values(): point_changes[horse.owner_id] = point_changes.get(horse.owner_id, 0) + config.PARTICIPANT_REWARD for bet in room.bets: point_changes[bet.user_id] = point_changes.get(bet.user_id, 0) - bet.amount champion = room.horses.get(room.champion_name) if champion: point_changes[champion.owner_id] = point_changes.get(champion.owner_id, 0) + config.CHAMPION_REWARD for bet in room.bets: if bet.horse_name == room.champion_name: payout = int(bet.amount * odds.get(bet.horse_name, config.MIN_ODDS)) point_changes[bet.user_id] = point_changes.get(bet.user_id, 0) + payout point_summaries = { user_id: _describe_points_delta(delta) for user_id, delta in point_changes.items() } return point_changes, point_summaries def _build_participants_snapshot(room: Room) -> list[str]: """生成赛果归档所需的参赛马名快照。""" return [horse.name for horse in _get_horses_in_order(room)] def _build_bet_distribution(room: Room) -> dict[str, int]: """按马名汇总下注分布,供 xapi 原样归档。""" distribution = {horse.name: 0 for horse in _get_horses_in_order(room)} for bet in room.bets: distribution[bet.horse_name] = distribution.get(bet.horse_name, 0) + bet.amount return distribution async def _send_to_scope(bot: Bot, scope: str, message: str, message_type: str = "race_update", critical: bool = False): """Send message to group or private chat based on scope.""" outbound_message: str | Message = message if config.RACE_RENDER_AS_IMAGE: try: outbound_message = _build_race_image_message(message) except Exception as e: logger.warning(f"_build_race_image_message failed, using plain text: {e}") outbound_message = message try: await message_service.send_with_recall(bot, scope, message_type, outbound_message) except Exception as e: if critical: logger.warning(f"_send_to_scope failed (critical={critical}) for {scope} [{message_type}]: {e}, retrying...") try: await message_service.send_with_recall(bot, scope, message_type, message) except Exception as e2: logger.error(f"_send_to_scope retry also failed for {scope} [{message_type}]: {e2}") else: logger.warning(f"_send_to_scope failed for {scope} [{message_type}]: {e}") async def _format_point_change_lines(room: Room, point_changes: dict[str, int], point_summaries: dict[str, str], name_map: dict[str, str]) -> list[str]: ordered_user_ids: list[str] = [] for horse in _get_horses_in_order(room): if horse.owner_id not in ordered_user_ids: ordered_user_ids.append(horse.owner_id) for bet in room.bets: if bet.user_id not in ordered_user_ids: ordered_user_ids.append(bet.user_id) lines = ["积分变化:"] for user_id in ordered_user_ids: delta = point_changes.get(user_id, 0) summary = point_summaries.get(user_id, _describe_points_delta(delta)) display_name = name_map.get(user_id, user_id) balance = await points_service.get_balance(user_id) lines.append(f" {display_name} {delta:+d} 积分 · {summary}(余额: {balance})") return lines def calculate_odds(room: Room) -> dict[str, float]: """Calculate odds for each horse based on bet distribution.""" total_bet = sum(b.amount for b in room.bets) odds = {} for name in room.horses: horse_bet = sum(b.amount for b in room.bets if b.horse_name == name) if horse_bet == 0: odds[name] = config.MIN_ODDS else: raw_odds = total_bet / horse_bet odds[name] = max(config.MIN_ODDS, round(raw_odds, 2)) return odds async def settle_race(room: Room) -> tuple[RaceResult, dict[str, float]] | None: """Settle bets and rewards after race finishes. Returns (result, odds) or None.""" champion = room.horses.get(room.champion_name) if not champion: return None odds = calculate_odds(room) # Collect all affected user IDs user_ids: set[str] = set() for horse in room.horses.values(): user_ids.add(horse.owner_id) for bet in room.bets: user_ids.add(bet.user_id) # Record pre-balances pre_balances: dict[str, int] = {} for uid in user_ids: pre_balances[uid] = await points_service.get_balance(uid) # 1. Reward all participants for horse in room.horses.values(): try: await points_service.reward_participant(horse.owner_id) except Exception as e: logger.warning(f"reward_participant failed for {horse.owner_id}: {e}") # 2. Champion bonus try: await points_service.reward_champion(champion.owner_id) except Exception as e: logger.warning(f"reward_champion failed for {champion.owner_id}: {e}") # 3. Bet payouts for winners for bet in room.bets: if bet.horse_name == room.champion_name: try: await points_service.payout_winnings( bet.user_id, bet.amount, odds.get(bet.horse_name, config.MIN_ODDS) ) except Exception as e: logger.warning(f"payout_winnings failed for {bet.user_id}: {e}") # Record post-balances and compute deltas post_balances: dict[str, int] = {} for uid in user_ids: post_balances[uid] = await points_service.get_balance(uid) point_changes: dict[str, int] = {} for uid in user_ids: delta = post_balances[uid] - pre_balances[uid] if delta != 0: point_changes[uid] = delta # Build human-readable summaries _, point_change_summaries = _build_point_changes(room, odds) result = RaceResult( race_id=str(uuid4()), scope=room.scope, champion_name=room.champion_name, champion_owner=champion.owner_id, participants=_build_participants_snapshot(room), bet_distribution=_build_bet_distribution(room), duration_ticks=room.tick_count, completed_at=datetime.now(), point_changes=point_changes, point_change_summaries=point_change_summaries, odds_snapshot=odds, ) return result, odds async def run_race_with_settlement(bot: Bot, room: Room, scope: str): """Run race with live progress updates and settlement.""" room.state = RoomState.RUNNING horse_list = "\n".join(f" {_format_horse_label(h)}" for h in _get_horses_in_order(room)) await _send_to_scope(bot, scope, f"比赛开始!\n{horse_list}", "race_update") try: while room.state == RoomState.RUNNING: await asyncio.sleep(config.RACE_TICK_INTERVAL) finished = race_engine.tick(room) progress = race_engine.format_progress(room) await _send_to_scope(bot, scope, progress, "race_update") if finished: champion = race_engine.determine_champion(finished) room.champion_name = champion.name room.state = RoomState.FINISHED break except asyncio.CancelledError: room.state = RoomState.INTERRUPTED for bet in room.bets: await points_service.refund_bet_points(bet.user_id, bet.amount, "比赛中断退还") return settlement = await settle_race(room) result = settlement[0] if settlement else None odds = settlement[1] if settlement else {} all_user_ids = [h.owner_id for h in room.horses.values()] + [b.user_id for b in room.bets] name_map = await _build_name_map(bot, scope, all_user_ids) champion = room.horses.get(room.champion_name) champion_name_display = name_map.get(champion.owner_id, champion.owner_id) if champion else '?' result_lines = [ f"比赛结束!冠军:{_format_horse_label(champion) if champion else room.champion_name}", f"马主 {champion_name_display} 获得 {config.CHAMPION_REWARD} 积分", ] winning_bets = [b for b in room.bets if b.horse_name == room.champion_name] if winning_bets: result_lines.append("下注中奖:") for b in winning_bets: payout = int(b.amount * odds.get(b.horse_name, config.MIN_ODDS)) bettor_name = name_map.get(b.user_id, b.user_id) result_lines.append(f" {bettor_name} 下注 {b.amount} -> 获得 {payout}") if result: result_lines.extend(await _format_point_change_lines(room, result.point_changes, result.point_change_summaries, name_map)) await message_service.recall_previous_of_type(bot, scope, "race_update") await _send_to_scope(bot, scope, "\n".join(result_lines), "race_result") if result: await room_store.save_race_result(result) race_engine.stop_race(scope) room_store.delete_room(scope) message_service.clear_pending_recalls(scope) # Import and re-export access functions from access.py (canonical source) from .access import get_event_id, get_scope, check_access