diff --git a/danding_bot/plugins/group_horse_racing/commands.py b/danding_bot/plugins/group_horse_racing/commands.py index 30adfdd..6f480ed 100644 --- a/danding_bot/plugins/group_horse_racing/commands.py +++ b/danding_bot/plugins/group_horse_racing/commands.py @@ -1,603 +1,607 @@ -import asyncio -import uuid -from datetime import datetime - -from nonebot import on_command -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 RoomStore -from .points_service import PointsService -from .race_engine import RaceEngine -from .message_service import MessageService -from .models import Room, Horse, Bet, HorseState, RoomState, RaceResult - -# Import config from __init__ to ensure it's loaded through NoneBot driver -from . import plugin_config as config - -room_store = RoomStore(config) -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: - pass - 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 get_scope(event: Event) -> str: - """Get room scope from event.""" - if isinstance(event, GroupMessageEvent): - return f"group_{event.group_id}" - elif isinstance(event, PrivateMessageEvent): - return f"test_{event.user_id}" - return "" - - -async def check_access(bot: Bot, event: Event) -> bool: - """Check if user has access to horse racing.""" - if isinstance(event, PrivateMessageEvent): - if not config.TEST_MODE: - return False - return event.user_id in config.TESTERS - - if isinstance(event, GroupMessageEvent): - if config.TEST_MODE: - return event.group_id in config.TEST_GROUPS - return event.group_id in config.ALLOWED_GROUPS - - return False - - -def get_event_id(event: Event) -> str: - """Get user id as string from event.""" - return str(event.user_id) - - -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 "略有损失" - - -def _build_point_changes(room: Room, odds: dict[str, float]) -> tuple[dict[str, int], dict[str, str]]: - point_changes: dict[str, int] = {} - - # Participation reward for all horse owners - 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 - - -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) -> RaceResult | None: - """Settle bets and rewards after race finishes.""" - champion = room.horses.get(room.champion_name) - if not champion: - return None - - # Reward all participants - for horse in room.horses.values(): - await points_service.reward_participant(horse.owner_id) - - # Reward champion owner - await points_service.reward_champion(champion.owner_id) - - # Settle bets - odds = calculate_odds(room) - for bet in room.bets: - if bet.horse_name == room.champion_name: - await points_service.payout_winnings(bet.user_id, bet.amount, odds.get(bet.horse_name, config.MIN_ODDS)) - - # Save race result - point_changes, point_summaries = _build_point_changes(room, odds) - result = RaceResult( - race_id=str(uuid.uuid4()), - scope=room.scope, - champion_name=champion.name, - champion_owner=champion.owner_id, - participants=[h.name for h in _get_horses_in_order(room)], - bet_distribution={name: sum(b.amount for b in room.bets if b.horse_name == name) for name in room.horses}, - duration_ticks=room.tick_count, - completed_at=datetime.now(), - point_changes=point_changes, - point_change_summaries=point_summaries, - ) - room_store.save_race_result(result) - return result - - -async def _send_to_scope(bot: Bot, scope: str, message: str, message_type: str = "race_update"): - """Send message to group or private chat based on scope.""" - try: - outbound_message: str | Message = message - if config.RACE_RENDER_AS_IMAGE: - outbound_message = _build_race_image_message(message) - - await message_service.send_with_recall(bot, scope, message_type, outbound_message) - except Exception: - pass - - -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") - - # Race loop with progress updates - 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 - - # Settle - result = await settle_race(room) - - # Build user_id -> display name mapping - 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) - - odds = calculate_odds(room) - 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)) - - # Before sending result, we can recall the last update - await message_service.recall_previous_of_type(bot, scope, "race_update") - await _send_to_scope(bot, scope, "\n".join(result_lines), "race_result") - - # Cleanup - race_engine.stop_race(scope) - room_store.delete_room(scope) - message_service.clear_pending_recalls(scope) - - -# --- Commands --- - -register_cmd = on_command("赛马报名", priority=5) - - -@register_cmd.handle() -async def handle_register(bot: Bot, event: Event): - """Handle horse registration.""" - if not await check_access(bot, event): - await register_cmd.finish("无权限访问此功能") - return - - msg = str(event.get_message()).strip() - parts = msg.split(None, 1) - user_id = get_event_id(event) - horse_name = parts[1].strip() if len(parts) > 1 else room_store.get_last_horse_name(user_id) or "" - - if not horse_name: - scope = get_scope(event) - horse_name = await _get_user_name(bot, scope, user_id) - # Ensure name is not too long when using nickname as default - if len(horse_name) > 10: - horse_name = horse_name[:10] - - if len(horse_name) > 10: - await register_cmd.finish("马匹名不能超过10个字符") - return - - scope = get_scope(event) - lock = room_store.get_lock(scope) - - async with lock: - room = room_store.get_room(scope) - if not room: - room = room_store.create_room(scope) - - if room.state != RoomState.WAITING: - await register_cmd.finish("比赛正在进行中,无法报名") - return - - if len(room.horses) >= 8: - await register_cmd.finish("房间已满(最多8匹马)") - return - - duplicate_horse = _find_duplicate_horse(room, horse_name) - if duplicate_horse: - await register_cmd.finish(f"马匹名 \"{horse_name}\" 已被 {_format_horse_label(duplicate_horse)} 使用") - return - - existing_user_horse = _find_user_horse(room, user_id) - if existing_user_horse: - await register_cmd.finish(f"你已经报名了,当前马匹为 {_format_horse_label(existing_user_horse)}") - return - - horse_index = room.next_horse_index - room.next_horse_index += 1 - room.horses[horse_name] = Horse(owner_id=user_id, name=horse_name, index=horse_index) - room_store.set_last_horse_name(user_id, horse_name) - - count = len(room.horses) - registered_horse = room.horses[horse_name] - await register_cmd.finish(f"报名成功!{_format_horse_label(registered_horse)} 已加入比赛({count}/8)") - - -cancel_cmd = on_command("赛马取消报名", priority=5) - - -@cancel_cmd.handle() -async def handle_cancel(bot: Bot, event: Event): - """Handle cancel registration.""" - if not await check_access(bot, event): - await cancel_cmd.finish("无权限访问此功能") - return - - scope = get_scope(event) - user_id = get_event_id(event) - lock = room_store.get_lock(scope) - - async with lock: - room = room_store.get_room(scope) - if not room: - await cancel_cmd.finish("房间不存在") - return - - if room.state != RoomState.WAITING: - await cancel_cmd.finish("比赛正在进行中,无法取消报名") - return - - user_horse = _find_user_horse(room, user_id) - if not user_horse: - await cancel_cmd.finish("你还没有报名") - return - - bets_to_refund = [b for b in room.bets if b.horse_name == user_horse.name] - for bet in bets_to_refund: - await points_service.refund_bet_points(bet.user_id, bet.amount, "取消报名退还下注") - room.bets = [b for b in room.bets if b.horse_name != user_horse.name] - - del room.horses[user_horse.name] - - await cancel_cmd.finish(f"已取消报名,{_format_horse_label(user_horse)} 已退出") - - -bet_cmd = on_command("赛马下注", priority=5) - - -@bet_cmd.handle() -async def handle_bet(bot: Bot, event: Event): - """Handle bet placement.""" - if not await check_access(bot, event): - await bet_cmd.finish("无权限访问此功能") - return - - msg = str(event.get_message()).strip() - parts = msg.split() - if len(parts) < 3: - await bet_cmd.finish("请使用:/赛马下注 <序号|马匹名> <金额>") - return - - horse_selector = parts[1] - try: - amount = int(parts[2]) - except ValueError: - await bet_cmd.finish("金额必须是正整数") - return - - if amount < config.MIN_BET: - await bet_cmd.finish(f"最低下注金额为 {config.MIN_BET}") - return - - scope = get_scope(event) - user_id = get_event_id(event) - lock = room_store.get_lock(scope) - - async with lock: - room = room_store.get_room(scope) - if not room: - await bet_cmd.finish("房间不存在,请先报名") - return - - if room.state != RoomState.WAITING: - await bet_cmd.finish("比赛正在进行中,无法下注") - return - - horse = _resolve_horse_selector(room, horse_selector) - if not horse: - await bet_cmd.finish(f"马匹序号/名称 \"{horse_selector}\" 不存在") - return - - success, balance = await points_service.spend_bet_points(user_id, amount, f"下注 {_format_horse_label(horse)}") - if not success: - await bet_cmd.finish(f"积分不足(当前余额:{balance})") - return - - room.bets.append(Bet(user_id=user_id, horse_name=horse.name, amount=amount)) - - odds = calculate_odds(room) - await bet_cmd.finish(f"下注成功!{_format_horse_label(horse)} {amount}积分(当前赔率:{odds.get(horse.name, config.MIN_ODDS):.2f})") - - -odds_cmd = on_command("赛马赔率", priority=5) - - -@odds_cmd.handle() -async def handle_odds(bot: Bot, event: Event): - """Handle odds display.""" - if not await check_access(bot, event): - await odds_cmd.finish("无权限访问此功能") - return - - scope = get_scope(event) - room = room_store.get_room(scope) - if not room: - await odds_cmd.finish("房间不存在,请先报名") - return - - if not room.horses: - await odds_cmd.finish("还没有马匹报名") - return - - odds = calculate_odds(room) - lines = ["当前赔率:"] - total_bet = sum(b.amount for b in room.bets) - for horse in _get_horses_in_order(room): - odd = odds.get(horse.name, config.MIN_ODDS) - horse_bet = sum(b.amount for b in room.bets if b.horse_name == horse.name) - lines.append(f" {_format_horse_label(horse)} - {odd:.2f}倍 (总下注: {horse_bet})") - lines.append(f"总下注池: {total_bet}") - - await odds_cmd.finish("\n".join(lines)) - - -start_cmd = on_command("赛马开赛", priority=5) - - -@start_cmd.handle() -async def handle_start(bot: Bot, event: Event): - """Handle race start.""" - if not await check_access(bot, event): - await start_cmd.finish("无权限访问此功能") - return - - scope = get_scope(event) - lock = room_store.get_lock(scope) - - async with lock: - room = room_store.get_room(scope) - if not room: - await start_cmd.finish("房间不存在,请先报名") - return - - if room.state != RoomState.WAITING: - await start_cmd.finish("比赛已经在进行中") - return - - if len(room.horses) < 2: - await start_cmd.finish("至少需要2匹马才能开赛") - return - - # Set all horses to racing state - for horse in room.horses.values(): - horse.state = HorseState.RACING - - await start_cmd.send("比赛开始!") - - # Run race in background (outside command handler) - task = asyncio.create_task(run_race_with_settlement(bot, room, scope)) - race_engine.register_task(scope, task) - - -help_cmd = on_command("赛马帮助", priority=5) - - -@help_cmd.handle() -async def handle_help(bot: Bot, event: Event): - """Handle help command.""" - help_text = f"""🏇 赛马游戏帮助 - -📌 命令列表: -/赛马报名 <马匹名> - 报名参赛(最多8匹马) -/赛马报名 - 复用上次绑定的马名,若无则使用群昵称 -/赛马取消报名 - 取消报名并退还下注 -/赛马下注 <序号|马匹名> <金额> - 下注 -/赛马赔率 - 查看当前赔率和下注池 -/赛马开赛 - 开始比赛(至少2匹马) -/赛马帮助 - 显示此帮助 - -📏 规则说明: -• 最低下注金额:{config.MIN_BET} 积分 -• 参赛马匹上限:8匹 -• 开赛要求:至少2匹马报名 - -💰 奖励机制: -• 冠军马主:获得 {config.CHAMPION_REWARD} 积分 -• 下注中奖:下注金额 × 赔率 - -📊 赔率说明: -• 赔率根据各马匹下注总额动态计算 -• 下注越少的马,赔率越高 -• 最低赔率:{config.MIN_ODDS} 倍 - -🎮 游戏流程: -1️⃣ 玩家报名并绑定马匹名 -2️⃣ 玩家可以给任意马匹下注 -3️⃣ 满足开赛条件后,任意玩家可开赛 -4️⃣ 比赛实时进行,定期播报进度 -5️⃣ 比赛结束后结算积分和奖金""" - await help_cmd.finish(help_text) +import asyncio +import logging +import uuid +from datetime import datetime + +from nonebot import on_command +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 RoomStore +from .points_service import PointsService +from .race_engine import RaceEngine +from .message_service import MessageService +from .models import Room, Horse, Bet, HorseState, RoomState, RaceResult + +# Import config from __init__ to ensure it's loaded through NoneBot driver +from . import plugin_config as config + +logger = logging.getLogger("horse_racing.commands") + +room_store = RoomStore(config) +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: + pass + 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 get_scope(event: Event) -> str: + """Get room scope from event.""" + if isinstance(event, GroupMessageEvent): + return f"group_{event.group_id}" + elif isinstance(event, PrivateMessageEvent): + return f"test_{event.user_id}" + return "" + + +async def check_access(bot: Bot, event: Event) -> bool: + """Check if user has access to horse racing.""" + if isinstance(event, PrivateMessageEvent): + if not config.TEST_MODE: + return False + return event.user_id in config.TESTERS + + if isinstance(event, GroupMessageEvent): + if config.TEST_MODE: + return event.group_id in config.TEST_GROUPS + return event.group_id in config.ALLOWED_GROUPS + + return False + + +def get_event_id(event: Event) -> str: + """Get user id as string from event.""" + return str(event.user_id) + + +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 "略有损失" + + +def _build_point_changes(room: Room, odds: dict[str, float]) -> tuple[dict[str, int], dict[str, str]]: + point_changes: dict[str, int] = {} + + # Participation reward for all horse owners + 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 + + +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) -> RaceResult | None: + """Settle bets and rewards after race finishes.""" + champion = room.horses.get(room.champion_name) + if not champion: + return None + + # Reward all participants + for horse in room.horses.values(): + await points_service.reward_participant(horse.owner_id) + + # Reward champion owner + await points_service.reward_champion(champion.owner_id) + + # Settle bets + odds = calculate_odds(room) + for bet in room.bets: + if bet.horse_name == room.champion_name: + await points_service.payout_winnings(bet.user_id, bet.amount, odds.get(bet.horse_name, config.MIN_ODDS)) + + # Save race result + point_changes, point_summaries = _build_point_changes(room, odds) + result = RaceResult( + race_id=str(uuid.uuid4()), + scope=room.scope, + champion_name=champion.name, + champion_owner=champion.owner_id, + participants=[h.name for h in _get_horses_in_order(room)], + bet_distribution={name: sum(b.amount for b in room.bets if b.horse_name == name) for name in room.horses}, + duration_ticks=room.tick_count, + completed_at=datetime.now(), + point_changes=point_changes, + point_change_summaries=point_summaries, + odds_snapshot=odds, + ) + await room_store.save_race_result(result) + return result + + +async def _send_to_scope(bot: Bot, scope: str, message: str, message_type: str = "race_update"): + """Send message to group or private chat based on scope.""" + try: + outbound_message: str | Message = message + if config.RACE_RENDER_AS_IMAGE: + outbound_message = _build_race_image_message(message) + + await message_service.send_with_recall(bot, scope, message_type, outbound_message) + except Exception as e: + logger.warning(f"_send_to_scope failed for {scope} [{message_type}]: {e}") + + +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") + + # Race loop with progress updates + 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 + + # Settle + result = await settle_race(room) + + # Build user_id -> display name mapping + 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) + + odds = calculate_odds(room) + 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)) + + # Before sending result, we can recall the last update + await message_service.recall_previous_of_type(bot, scope, "race_update") + await _send_to_scope(bot, scope, "\n".join(result_lines), "race_result") + + # Cleanup + race_engine.stop_race(scope) + room_store.delete_room(scope) + message_service.clear_pending_recalls(scope) + + +# --- Commands --- + +register_cmd = on_command("赛马报名", priority=5) + + +@register_cmd.handle() +async def handle_register(bot: Bot, event: Event): + """Handle horse registration.""" + if not await check_access(bot, event): + await register_cmd.finish("无权限访问此功能") + return + + msg = str(event.get_message()).strip() + parts = msg.split(None, 1) + user_id = get_event_id(event) + horse_name = parts[1].strip() if len(parts) > 1 else await room_store.get_last_horse_name(user_id) or "" + + if not horse_name: + scope = get_scope(event) + horse_name = await _get_user_name(bot, scope, user_id) + # Ensure name is not too long when using nickname as default + if len(horse_name) > 10: + horse_name = horse_name[:10] + + if len(horse_name) > 10: + await register_cmd.finish("马匹名不能超过10个字符") + return + + scope = get_scope(event) + lock = room_store.get_lock(scope) + + async with lock: + room = room_store.get_room(scope) + if not room: + room = await room_store.create_room(scope) + + if room.state != RoomState.WAITING: + await register_cmd.finish("比赛正在进行中,无法报名") + return + + if len(room.horses) >= 8: + await register_cmd.finish("房间已满(最多8匹马)") + return + + duplicate_horse = _find_duplicate_horse(room, horse_name) + if duplicate_horse: + await register_cmd.finish(f"马匹名 \"{horse_name}\" 已被 {_format_horse_label(duplicate_horse)} 使用") + return + + existing_user_horse = _find_user_horse(room, user_id) + if existing_user_horse: + await register_cmd.finish(f"你已经报名了,当前马匹为 {_format_horse_label(existing_user_horse)}") + return + + horse_index = room.next_horse_index + room.next_horse_index += 1 + room.horses[horse_name] = Horse(owner_id=user_id, name=horse_name, index=horse_index) + await room_store.set_last_horse_name(user_id, horse_name) + + count = len(room.horses) + registered_horse = room.horses[horse_name] + await register_cmd.finish(f"报名成功!{_format_horse_label(registered_horse)} 已加入比赛({count}/8)") + + +cancel_cmd = on_command("赛马取消报名", priority=5) + + +@cancel_cmd.handle() +async def handle_cancel(bot: Bot, event: Event): + """Handle cancel registration.""" + if not await check_access(bot, event): + await cancel_cmd.finish("无权限访问此功能") + return + + scope = get_scope(event) + user_id = get_event_id(event) + lock = room_store.get_lock(scope) + + async with lock: + room = room_store.get_room(scope) + if not room: + await cancel_cmd.finish("房间不存在") + return + + if room.state != RoomState.WAITING: + await cancel_cmd.finish("比赛正在进行中,无法取消报名") + return + + user_horse = _find_user_horse(room, user_id) + if not user_horse: + await cancel_cmd.finish("你还没有报名") + return + + bets_to_refund = [b for b in room.bets if b.horse_name == user_horse.name] + for bet in bets_to_refund: + await points_service.refund_bet_points(bet.user_id, bet.amount, "取消报名退还下注") + room.bets = [b for b in room.bets if b.horse_name != user_horse.name] + + del room.horses[user_horse.name] + + await cancel_cmd.finish(f"已取消报名,{_format_horse_label(user_horse)} 已退出") + + +bet_cmd = on_command("赛马下注", priority=5) + + +@bet_cmd.handle() +async def handle_bet(bot: Bot, event: Event): + """Handle bet placement.""" + if not await check_access(bot, event): + await bet_cmd.finish("无权限访问此功能") + return + + msg = str(event.get_message()).strip() + parts = msg.split() + if len(parts) < 3: + await bet_cmd.finish("请使用:/赛马下注 <序号|马匹名> <金额>") + return + + horse_selector = parts[1] + try: + amount = int(parts[2]) + except ValueError: + await bet_cmd.finish("金额必须是正整数") + return + + if amount < config.MIN_BET: + await bet_cmd.finish(f"最低下注金额为 {config.MIN_BET}") + return + + scope = get_scope(event) + user_id = get_event_id(event) + lock = room_store.get_lock(scope) + + async with lock: + room = room_store.get_room(scope) + if not room: + await bet_cmd.finish("房间不存在,请先报名") + return + + if room.state != RoomState.WAITING: + await bet_cmd.finish("比赛正在进行中,无法下注") + return + + horse = _resolve_horse_selector(room, horse_selector) + if not horse: + await bet_cmd.finish(f"马匹序号/名称 \"{horse_selector}\" 不存在") + return + + success, balance = await points_service.spend_bet_points(user_id, amount, f"下注 {_format_horse_label(horse)}") + if not success: + await bet_cmd.finish(f"积分不足(当前余额:{balance})") + return + + room.bets.append(Bet(user_id=user_id, horse_name=horse.name, amount=amount)) + + odds = calculate_odds(room) + await bet_cmd.finish(f"下注成功!{_format_horse_label(horse)} {amount}积分(当前赔率:{odds.get(horse.name, config.MIN_ODDS):.2f})") + + +odds_cmd = on_command("赛马赔率", priority=5) + + +@odds_cmd.handle() +async def handle_odds(bot: Bot, event: Event): + """Handle odds display.""" + if not await check_access(bot, event): + await odds_cmd.finish("无权限访问此功能") + return + + scope = get_scope(event) + room = room_store.get_room(scope) + if not room: + await odds_cmd.finish("房间不存在,请先报名") + return + + if not room.horses: + await odds_cmd.finish("还没有马匹报名") + return + + odds = calculate_odds(room) + lines = ["当前赔率:"] + total_bet = sum(b.amount for b in room.bets) + for horse in _get_horses_in_order(room): + odd = odds.get(horse.name, config.MIN_ODDS) + horse_bet = sum(b.amount for b in room.bets if b.horse_name == horse.name) + lines.append(f" {_format_horse_label(horse)} - {odd:.2f}倍 (总下注: {horse_bet})") + lines.append(f"总下注池: {total_bet}") + + await odds_cmd.finish("\n".join(lines)) + + +start_cmd = on_command("赛马开赛", priority=5) + + +@start_cmd.handle() +async def handle_start(bot: Bot, event: Event): + """Handle race start.""" + if not await check_access(bot, event): + await start_cmd.finish("无权限访问此功能") + return + + scope = get_scope(event) + lock = room_store.get_lock(scope) + + async with lock: + room = room_store.get_room(scope) + if not room: + await start_cmd.finish("房间不存在,请先报名") + return + + if room.state != RoomState.WAITING: + await start_cmd.finish("比赛已经在进行中") + return + + if len(room.horses) < 2: + await start_cmd.finish("至少需要2匹马才能开赛") + return + + # Set all horses to racing state + for horse in room.horses.values(): + horse.state = HorseState.RACING + + await start_cmd.send("比赛开始!") + + # Run race in background (outside command handler) + task = asyncio.create_task(run_race_with_settlement(bot, room, scope)) + race_engine.register_task(scope, task) + + +help_cmd = on_command("赛马帮助", priority=5) + + +@help_cmd.handle() +async def handle_help(bot: Bot, event: Event): + """Handle help command.""" + help_text = f"""🏇 赛马游戏帮助 + +📌 命令列表: +/赛马报名 <马匹名> - 报名参赛(最多8匹马) +/赛马报名 - 复用上次绑定的马名,若无则使用群昵称 +/赛马取消报名 - 取消报名并退还下注 +/赛马下注 <序号|马匹名> <金额> - 下注 +/赛马赔率 - 查看当前赔率和下注池 +/赛马开赛 - 开始比赛(至少2匹马) +/赛马帮助 - 显示此帮助 + +📏 规则说明: +• 最低下注金额:{config.MIN_BET} 积分 +• 参赛马匹上限:8匹 +• 开赛要求:至少2匹马报名 + +💰 奖励机制: +• 冠军马主:获得 {config.CHAMPION_REWARD} 积分 +• 下注中奖:下注金额 × 赔率 + +📊 赔率说明: +• 赔率根据各马匹下注总额动态计算 +• 下注越少的马,赔率越高 +• 最低赔率:{config.MIN_ODDS} 倍 + +🎮 游戏流程: +1️⃣ 玩家报名并绑定马匹名 +2️⃣ 玩家可以给任意马匹下注 +3️⃣ 满足开赛条件后,任意玩家可开赛 +4️⃣ 比赛实时进行,定期播报进度 +5️⃣ 比赛结束后结算积分和奖金""" + await help_cmd.finish(help_text) diff --git a/danding_bot/plugins/group_horse_racing/points_service.py b/danding_bot/plugins/group_horse_racing/points_service.py index dda7a44..c925ab0 100644 --- a/danding_bot/plugins/group_horse_racing/points_service.py +++ b/danding_bot/plugins/group_horse_racing/points_service.py @@ -1,63 +1,86 @@ -from typing import Tuple -from danding_bot.plugins.danding_points import points_api -from .config import Config - - -class PointsService: - def __init__(self, config: Config): - self.config = config - - async def spend_bet_points( - self, user_id: str, amount: int, reason: str = "赛马下注" - ) -> Tuple[bool, int]: - """Deduct points for betting with retry.""" - success, balance = await points_api.spend_points( - user_id, amount, "horse_race", reason - ) - if not success: - success, balance = await points_api.spend_points( - user_id, amount, "horse_race", reason - ) - return success, balance - - async def refund_bet_points( - self, user_id: str, amount: int, reason: str = "取消报名退还" - ) -> Tuple[bool, int]: - """Refund bet points.""" - return await points_api.add_points(user_id, amount, "horse_race", reason) - - async def payout_winnings( - self, user_id: str, amount: int, odds: float - ) -> Tuple[bool, int]: - """Payout bet winnings.""" - payout = int(amount * odds) - reason = f"下注获胜 ×{odds:.2f}" - return await points_api.add_points(user_id, payout, "horse_race", reason) - - async def reward_participant(self, user_id: str) -> Tuple[bool, int]: - """Reward race participant.""" - return await points_api.add_points( - user_id, - self.config.PARTICIPANT_REWARD, - "horse_race", - "参赛奖励", - ) - - async def reward_champion(self, user_id: str) -> Tuple[bool, int]: - """Reward race champion.""" - return await points_api.add_points( - user_id, - self.config.CHAMPION_REWARD, - "horse_race", - "冠军奖励", - ) - - async def set_points( - self, user_id: str, amount: int, reason: str = "测试设置积分" - ) -> Tuple[bool, int]: - """Set user points (for testing).""" - return await points_api.set_points(user_id, amount, "horse_race", reason) - - async def get_balance(self, user_id: str) -> int: - """Get user balance.""" - return await points_api.get_balance(user_id) +import asyncio +import logging +from typing import Tuple +from danding_bot.plugins.danding_points import points_api +from .config import Config + +logger = logging.getLogger("horse_racing.points") + +MAX_RETRIES = 3 +RETRY_DELAY = 0.5 + + +class PointsService: + def __init__(self, config: Config): + self.config = config + + async def _call_with_retry(self, func, *args, retries=MAX_RETRIES): + """Call API function with retry logic.""" + last_exc = None + for attempt in range(retries): + try: + return await func(*args) + except Exception as e: + last_exc = e + logger.warning( + f"Points API call failed (attempt {attempt + 1}/{retries}): {e}" + ) + if attempt < retries - 1: + await asyncio.sleep(RETRY_DELAY) + logger.error(f"Points API call failed after {retries} attempts: {last_exc}") + raise last_exc + + async def spend_bet_points( + self, user_id: str, amount: int, reason: str = "赛马下注" + ) -> Tuple[bool, int]: + """Deduct points for betting with retry.""" + try: + return await self._call_with_retry( + points_api.spend_points, + user_id, amount, "horse_race", reason + ) + except Exception as e: + logger.error(f"spend_bet_points failed for user {user_id}: {e}") + return False, 0 + + async def refund_bet_points( + self, user_id: str, amount: int, reason: str = "取消报名退还" + ) -> Tuple[bool, int]: + """Refund bet points.""" + return await points_api.add_points(user_id, amount, "horse_race", reason) + + async def payout_winnings( + self, user_id: str, amount: int, odds: float + ) -> Tuple[bool, int]: + """Payout bet winnings.""" + payout = int(amount * odds) + reason = f"下注获胜 ×{odds:.2f}" + return await points_api.add_points(user_id, payout, "horse_race", reason) + + async def reward_participant(self, user_id: str) -> Tuple[bool, int]: + """Reward race participant.""" + return await points_api.add_points( + user_id, + self.config.PARTICIPANT_REWARD, + "horse_race", + "参赛奖励", + ) + + async def reward_champion(self, user_id: str) -> Tuple[bool, int]: + """Reward race champion.""" + return await points_api.add_points( + user_id, + self.config.CHAMPION_REWARD, + "horse_race", + "冠军奖励", + ) + + async def set_points( + self, user_id: str, amount: int, reason: str = "测试设置积分" + ) -> Tuple[bool, int]: + """Set user points (for testing).""" + return await points_api.set_points(user_id, amount, "horse_race", reason) + + async def get_balance(self, user_id: str) -> int: + """Get user balance.""" + return await points_api.get_balance(user_id) diff --git a/danding_bot/plugins/group_horse_racing/room_store.py b/danding_bot/plugins/group_horse_racing/room_store.py index df2d403..063dcd6 100644 --- a/danding_bot/plugins/group_horse_racing/room_store.py +++ b/danding_bot/plugins/group_horse_racing/room_store.py @@ -1,163 +1,192 @@ -import asyncio -import sqlite3 -from datetime import datetime -from pathlib import Path -from typing import Optional - -from .models import Room, RoomState, RaceResult -from .config import Config - - -class RoomStore: - def __init__(self, config: Config): - self.config = config - self.rooms: dict[str, Room] = {} - self._locks: dict[str, asyncio.Lock] = {} - self.db_path = Path(config.RACE_DB_FILE) - self.db_path.parent.mkdir(parents=True, exist_ok=True) - self._init_db() - - def _init_db(self): - """Initialize database tables.""" - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS room_snapshots ( - scope TEXT PRIMARY KEY, - state TEXT NOT NULL, - created_at TEXT NOT NULL, - horses TEXT NOT NULL, - bets TEXT NOT NULL, - champion_name TEXT, - tick_count INTEGER DEFAULT 0 - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS race_history ( - race_id TEXT PRIMARY KEY, - scope TEXT NOT NULL, - champion_name TEXT NOT NULL, - champion_owner TEXT NOT NULL, - participants TEXT NOT NULL, - bet_distribution TEXT NOT NULL, - duration_ticks INTEGER NOT NULL, - completed_at TEXT NOT NULL - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS user_horse_names ( - user_id TEXT PRIMARY KEY, - horse_name TEXT NOT NULL - ) - """) - - conn.commit() - conn.close() - - def get_lock(self, scope: str) -> asyncio.Lock: - """Get or create per-room lock.""" - if scope not in self._locks: - self._locks[scope] = asyncio.Lock() - return self._locks[scope] - - def get_room(self, scope: str) -> Optional[Room]: - """Get room by scope.""" - return self.rooms.get(scope) - - def create_room(self, scope: str) -> Room: - """Create new room.""" - room = Room(scope=scope) - self.rooms[scope] = room - self._save_snapshot(room) - return room - - def delete_room(self, scope: str): - """Delete room.""" - if scope in self.rooms: - del self.rooms[scope] - - def get_last_horse_name(self, user_id: str) -> Optional[str]: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute("SELECT horse_name FROM user_horse_names WHERE user_id = ?", (user_id,)) - row = cursor.fetchone() - conn.close() - return row[0] if row else None - - def set_last_horse_name(self, user_id: str, horse_name: str): - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute( - "INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)", - (user_id, horse_name), - ) - conn.commit() - conn.close() - - def _save_snapshot(self, room: Room): - """Save room snapshot to database.""" - import json - - horses_json = json.dumps({ - name: { - "owner_id": horse.owner_id, - "name": horse.name, - "index": horse.index, - "position": horse.position, - "state": horse.state.value, - } - for name, horse in room.horses.items() - }) - - bets_json = json.dumps([ - { - "user_id": bet.user_id, - "horse_name": bet.horse_name, - "amount": bet.amount, - } - for bet in room.bets - ]) - - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute(""" - INSERT OR REPLACE INTO room_snapshots - (scope, state, created_at, horses, bets, champion_name, tick_count) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - room.scope, - room.state.value, - room.created_at.isoformat(), - horses_json, - bets_json, - room.champion_name, - room.tick_count, - )) - conn.commit() - conn.close() - - def save_race_result(self, result: RaceResult): - """Save race result to history.""" - import json - - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO race_history - (race_id, scope, champion_name, champion_owner, participants, bet_distribution, duration_ticks, completed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, ( - result.race_id, - result.scope, - result.champion_name, - result.champion_owner, - json.dumps(result.participants), - json.dumps(result.bet_distribution), - result.duration_ticks, - result.completed_at.isoformat(), - )) - conn.commit() - conn.close() +import asyncio +import aiosqlite +import json +from datetime import datetime +from pathlib import Path +from typing import Optional + +from .models import Room, RoomState, RaceResult +from .config import Config + + +class RoomStore: + def __init__(self, config: Config): + self.config = config + self.rooms: dict[str, Room] = {} + self._locks: dict[str, asyncio.Lock] = {} + self.db_path = Path(config.RACE_DB_FILE) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._initialized = False + + async def _init_db(self): + """Initialize database tables asynchronously.""" + if self._initialized: + return + + async with aiosqlite.connect(self.db_path) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS room_snapshots ( + scope TEXT PRIMARY KEY, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + horses TEXT NOT NULL, + bets TEXT NOT NULL, + champion_name TEXT, + tick_count INTEGER DEFAULT 0 + ) + """) + + await db.execute(""" + CREATE TABLE IF NOT EXISTS race_history ( + race_id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + champion_name TEXT NOT NULL, + champion_owner TEXT NOT NULL, + participants TEXT NOT NULL, + bet_distribution TEXT NOT NULL, + duration_ticks INTEGER NOT NULL, + completed_at TEXT NOT NULL, + point_changes TEXT DEFAULT '{}', + point_change_summaries TEXT DEFAULT '{}', + odds_snapshot TEXT DEFAULT '{}' + ) + """) + + await db.execute(""" + CREATE TABLE IF NOT EXISTS user_horse_names ( + user_id TEXT PRIMARY KEY, + horse_name TEXT NOT NULL + ) + """) + + # Add missing columns if they don't exist (for existing databases) + try: + await db.execute("SELECT point_changes FROM race_history LIMIT 1") + except aiosqlite.OperationalError: + await db.execute("ALTER TABLE race_history ADD COLUMN point_changes TEXT DEFAULT '{}'") + + try: + await db.execute("SELECT point_change_summaries FROM race_history LIMIT 1") + except aiosqlite.OperationalError: + await db.execute("ALTER TABLE race_history ADD COLUMN point_change_summaries TEXT DEFAULT '{}'") + + try: + await db.execute("SELECT odds_snapshot FROM race_history LIMIT 1") + except aiosqlite.OperationalError: + await db.execute("ALTER TABLE race_history ADD COLUMN odds_snapshot TEXT DEFAULT '{}'") + + await db.commit() + + self._initialized = True + + async def ensure_initialized(self): + """Ensure database is initialized (call before any DB operation).""" + if not self._initialized: + await self._init_db() + + def get_lock(self, scope: str) -> asyncio.Lock: + """Get or create per-room lock.""" + if scope not in self._locks: + self._locks[scope] = asyncio.Lock() + return self._locks[scope] + + def get_room(self, scope: str) -> Optional[Room]: + """Get room by scope.""" + return self.rooms.get(scope) + + async def create_room(self, scope: str) -> Room: + """Create new room.""" + room = Room(scope=scope) + self.rooms[scope] = room + await self._save_snapshot(room) + return room + + def delete_room(self, scope: str): + """Delete room.""" + if scope in self.rooms: + del self.rooms[scope] + + async def get_last_horse_name(self, user_id: str) -> Optional[str]: + await self.ensure_initialized() + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute( + "SELECT horse_name FROM user_horse_names WHERE user_id = ?", + (user_id,) + ) + row = await cursor.fetchone() + return row[0] if row else None + + async def set_last_horse_name(self, user_id: str, horse_name: str): + await self.ensure_initialized() + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + "INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)", + (user_id, horse_name), + ) + await db.commit() + + async def _save_snapshot(self, room: Room): + """Save room snapshot to database.""" + await self.ensure_initialized() + + horses_json = json.dumps({ + name: { + "owner_id": horse.owner_id, + "name": horse.name, + "index": horse.index, + "position": horse.position, + "state": horse.state.value, + } + for name, horse in room.horses.items() + }) + + bets_json = json.dumps([ + { + "user_id": bet.user_id, + "horse_name": bet.horse_name, + "amount": bet.amount, + } + for bet in room.bets + ]) + + async with aiosqlite.connect(self.db_path) as db: + await db.execute(""" + INSERT OR REPLACE INTO room_snapshots + (scope, state, created_at, horses, bets, champion_name, tick_count) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + room.scope, + room.state.value, + room.created_at.isoformat(), + horses_json, + bets_json, + room.champion_name, + room.tick_count, + )) + await db.commit() + + async def save_race_result(self, result: RaceResult): + """Save race result to history.""" + await self.ensure_initialized() + + async with aiosqlite.connect(self.db_path) as db: + await db.execute(""" + INSERT INTO race_history + (race_id, scope, champion_name, champion_owner, participants, + bet_distribution, duration_ticks, completed_at, + point_changes, point_change_summaries, odds_snapshot) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + result.race_id, + result.scope, + result.champion_name, + result.champion_owner, + json.dumps(result.participants), + json.dumps(result.bet_distribution), + result.duration_ticks, + result.completed_at.isoformat(), + json.dumps(getattr(result, 'point_changes', {})), + json.dumps(getattr(result, 'point_change_summaries', {})), + json.dumps(getattr(result, 'odds_snapshot', {})), + )) + await db.commit() diff --git a/danding_bot/plugins/group_horse_racing/test_commands.py b/danding_bot/plugins/group_horse_racing/test_commands.py index 3722b3f..6fdfdbb 100644 --- a/danding_bot/plugins/group_horse_racing/test_commands.py +++ b/danding_bot/plugins/group_horse_racing/test_commands.py @@ -1,413 +1,413 @@ -from nonebot import on_command -from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, PrivateMessageEvent - -from . import plugin_config as config -from .commands import get_scope, check_access, room_store, points_service, race_engine -from .models import Horse, HorseState, RoomState, Bet, RaceResult - -import asyncio -import random -from datetime import datetime -import traceback - -from . import commands as commands_mod - - -async def check_tester(event: Event) -> bool: - """Check if user is a tester.""" - if not config.TEST_MODE: - return False - return event.user_id in config.TESTERS - - -test_reset_points_cmd = on_command("测试重置积分", priority=5) - - -@test_reset_points_cmd.handle() -async def handle_test_reset_points(bot: Bot, event: Event): - """Reset user points to 1000 for testing.""" - if not await check_tester(event): - await test_reset_points_cmd.finish("权限不足") - return - - success, _ = await points_service.set_points(event.user_id, 1000, "测试重置积分") - if success: - await test_reset_points_cmd.finish("积分已重置为1000") - else: - await test_reset_points_cmd.finish("重置失败") - - -test_set_points_cmd = on_command("测试设置积分", priority=5) - - -@test_set_points_cmd.handle() -async def handle_test_set_points(bot: Bot, event: Event): - """Set user points for testing.""" - if not await check_tester(event): - await test_set_points_cmd.finish("权限不足") - return - - # Get the message text and extract amount - msg = str(event.get_message()).strip() - # Remove command prefix - parts = msg.split() - - if len(parts) < 2: - await test_set_points_cmd.finish("请使用: /测试设置积分 <金额>") - return - - try: - amount = int(parts[1]) - if amount < 0: - await test_set_points_cmd.finish("金额必须为非负数") - return - except ValueError: - await test_set_points_cmd.finish("金额必须是整数") - return - - success, _ = await points_service.set_points(event.user_id, amount, f"测试设置积分为{amount}") - if success: - await test_set_points_cmd.finish(f"积分已设置为 {amount}") - else: - await test_set_points_cmd.finish("设置失败") - - -test_query_points_cmd = on_command("测试查询积分", priority=5) - - -@test_query_points_cmd.handle() -async def handle_test_query_points(bot: Bot, event: Event): - """Query user points for testing.""" - if not await check_tester(event): - await test_query_points_cmd.finish("权限不足") - return - - balance = await points_service.get_balance(event.user_id) - await test_query_points_cmd.finish(f"当前积分: {balance}") - - -test_clear_room_cmd = on_command("测试清空房间", priority=5) - - -@test_clear_room_cmd.handle() -async def handle_test_clear_room(bot: Bot, event: Event): - """Clear test room.""" - if not await check_tester(event): - await test_clear_room_cmd.finish("权限不足") - return - - scope = get_scope(event) - room_store.delete_room(scope) - await test_clear_room_cmd.finish("房间已清空") - - -test_force_start_cmd = on_command("测试强制开赛", priority=5) - - -@test_force_start_cmd.handle() -async def handle_test_force_start(bot: Bot, event: Event): - """Force start race for testing.""" - if not await check_tester(event): - await test_force_start_cmd.finish("权限不足") - return - - await test_force_start_cmd.finish("测试强制开赛命令") - - -def _generate_random_horse_names(count: int) -> list[str]: - prefixes = ["赤焰", "踏雪", "追风", "流星", "疾电", "破晓", "青岚", "玄影", "星尘", "霜刃", "烈阳", "苍穹"] - cores = ["奔", "跃", "影", "翼", "刃", "雷", "岚", "焰", "星", "雪", "风", "光"] - suffixes = ["号", "骑", "王", "将", "卫", "客", "影", "者", "马", "军"] - - names: set[str] = set() - attempts = 0 - while len(names) < count and attempts < 500: - attempts += 1 - name = f"{random.choice(prefixes)}{random.choice(cores)}{random.choice(suffixes)}" - if len(name) > 10: - name = name[:10] - names.add(name) - - while len(names) < count: - names.add(f"测试马{len(names) + 1}") - - return list(names)[:count] - - -test_simulate_race_cmd = on_command("测试模拟赛马", aliases={"测试模拟"}, priority=1, block=True) - - -class _FakeBot: - def __init__(self): - self.messages: list[dict] = [] - self._next_message_id = 1 - - async def send_msg(self, **kwargs): - self.messages.append(dict(kwargs)) - message_id = self._next_message_id - self._next_message_id += 1 - return {"message_id": message_id} - - async def delete_msg(self, message_id: int): - # Simply record the deletion if needed, or do nothing - return - - -class _InMemoryRoomStore: - def __init__(self): - self.rooms: dict[str, "commands_mod.Room"] = {} - self.saved_results: list[RaceResult] = [] - - def get_room(self, scope: str): - return self.rooms.get(scope) - - def create_room(self, scope: str): - room = commands_mod.Room(scope=scope) - self.rooms[scope] = room - return room - - def delete_room(self, scope: str): - if scope in self.rooms: - del self.rooms[scope] - - def save_race_result(self, result: RaceResult): - self.saved_results.append(result) - - -class _InMemoryPointsService: - def __init__(self): - self.calls: list[tuple[str, dict]] = [] - - async def reward_champion(self, user_id: str): - self.calls.append(("reward_champion", {"user_id": user_id})) - return True, 0 - - async def reward_participant(self, user_id: str): - self.calls.append(("reward_participant", {"user_id": user_id})) - return True, 0 - - async def payout_winnings(self, user_id: str, amount: int, odds: float): - self.calls.append(("payout_winnings", {"user_id": user_id, "amount": amount, "odds": odds})) - return True, 0 - - async def refund_bet_points(self, user_id: str, amount: int, reason: str = "比赛中断退还"): - self.calls.append(("refund_bet_points", {"user_id": user_id, "amount": amount, "reason": reason})) - return True, 0 - - async def get_balance(self, user_id: str) -> int: - self.calls.append(("get_balance", {"user_id": user_id})) - return 8888 - - -class _NoopMessageService: - def __init__(self): - self.last_messages: dict[str, dict[str, str]] = {} - - def clear_pending_recalls(self, scope: str): - if scope in self.last_messages: - del self.last_messages[scope] - - async def send_with_recall(self, bot, scope, message_type, message): - # Support basic recall for race_update to avoid flooding during simulation - if message_type == "race_update": - await self.recall_previous_of_type(bot, scope, "race_update") - - is_group = scope.startswith("group_") - result = await bot.send_msg( - message_type="group" if is_group else "private", - group_id=int(scope.split("_", 1)[1]) if is_group else None, - user_id=int(scope.split("_", 1)[1]) if not is_group else None, - message=message, - ) - - if scope not in self.last_messages: - self.last_messages[scope] = {} - - if isinstance(result, dict) and "message_id" in result: - self.last_messages[scope][message_type] = result["message_id"] - - return "fake_msg_id" - - async def recall_previous_of_type(self, bot, scope, message_type): - if scope in self.last_messages and message_type in self.last_messages[scope]: - msg_id = self.last_messages[scope][message_type] - try: - await bot.delete_msg(message_id=msg_id) - except Exception: - pass - del self.last_messages[scope][message_type] - - -@test_simulate_race_cmd.handle() -async def handle_test_simulate_race(bot: Bot, event: Event): - if not await check_tester(event): - await test_simulate_race_cmd.send("权限不足") - return - - await test_simulate_race_cmd.send("收到:测试模拟赛马,开始执行完全模拟(无真实积分/数据库副作用)") - - raw_msg = str(event.get_message()).strip() - stream_progress = ("展示" in raw_msg) or ("实时" in raw_msg) or ("慢" in raw_msg) - - scope = get_scope(event) - try: - race_engine.stop_race(scope) - room_store.delete_room(scope) - except Exception: - pass - original_room_store = commands_mod.room_store - original_points_service = commands_mod.points_service - original_message_service = commands_mod.message_service - original_tick_interval = commands_mod.config.RACE_TICK_INTERVAL - original_stop_race = commands_mod.race_engine.stop_race - original_send_to_scope = commands_mod._send_to_scope - - - fake_room_store = _InMemoryRoomStore() - fake_points_service = _InMemoryPointsService() - fake_message_service = _NoopMessageService() - fake_bot = _FakeBot() - - start_task: asyncio.Task | None = None - room = None - - try: - await test_simulate_race_cmd.send("阶段:构建沙盒环境与参赛数据") - commands_mod.room_store = fake_room_store - commands_mod.points_service = fake_points_service - commands_mod.message_service = fake_message_service - commands_mod.config.RACE_TICK_INTERVAL = 1 if stream_progress else 0 - commands_mod.race_engine.stop_race = lambda _scope: commands_mod.race_engine.active_tasks.pop(_scope, None) - - progress_count = 0 - max_progress = 30 - - async def _test_send_to_scope(_bot: Bot, _scope: str, message: str, *args, **kwargs): - nonlocal progress_count - await fake_bot.send_msg(message_type="private", user_id=event.user_id, message=message) - if not stream_progress: - return - - if message.startswith("【第") and "回合】" in message: - progress_count += 1 - if progress_count > max_progress: - return - await original_send_to_scope(bot, scope, message, *args, **kwargs) - - commands_mod._send_to_scope = _test_send_to_scope - - room = fake_room_store.create_room(scope) - horse_names = _generate_random_horse_names(8) - for idx, horse_name in enumerate(horse_names, start=1): - owner_id = f"sim_user_{idx}" - room.horses[horse_name] = Horse(owner_id=owner_id, name=horse_name, index=idx, state=HorseState.RACING) - room.next_horse_index = len(horse_names) + 1 - - bet_amount = max(commands_mod.config.MIN_BET, 10) - room.bets.append(Bet(user_id="bettor_1", horse_name=horse_names[0], amount=bet_amount)) - room.bets.append(Bet(user_id="bettor_2", horse_name=horse_names[1], amount=bet_amount * 2)) - - room.state = RoomState.WAITING - - for horse in room.horses.values(): - horse.state = HorseState.RACING - - await test_simulate_race_cmd.send("阶段:执行赛程(后台任务)") - start_task = asyncio.create_task(commands_mod.run_race_with_settlement(fake_bot, room, scope)) - commands_mod.race_engine.register_task(scope, start_task) - await asyncio.wait_for(start_task, timeout=180 if stream_progress else 30) - - messages = [str(m.get("message", "")) for m in fake_bot.messages] - if not messages: - await test_simulate_race_cmd.send("完全模拟失败:未捕获到任何消息") - return - - if not any("比赛开始!" in msg for msg in messages): - await test_simulate_race_cmd.send("完全模拟失败:未发送开赛消息") - return - - # Look for the start message to verify horse names - start_msg = next((msg for msg in messages if "比赛开始!" in msg), "") - for idx, horse_name in enumerate(horse_names, start=1): - if f"{idx:02d}号 {horse_name}" not in start_msg: - await test_simulate_race_cmd.send(f"完全模拟失败:开赛名单中未找到 {idx:02d}号 {horse_name}") - return - - progress_messages = [msg for msg in messages if "【第" in msg and "回合】" in msg] - if not progress_messages: - await test_simulate_race_cmd.send("完全模拟失败:未发送回合进度消息") - return - - # Check first progress message format - progress_lines = [line for line in progress_messages[0].splitlines() if "|" in line] - if len(progress_lines) != len(horse_names): - await test_simulate_race_cmd.send("完全模拟失败:回合进度展示条目数量不匹配") - return - - if not any("比赛结束!冠军:" in msg for msg in messages): - await test_simulate_race_cmd.send("完全模拟失败:未发送结束结算消息") - return - - if not any("积分变化:" in msg for msg in messages): - await test_simulate_race_cmd.send("完全模拟失败:未发送积分变化总结") - return - - if not fake_room_store.saved_results: - await test_simulate_race_cmd.send("完全模拟失败:未写入赛史结果(内存)") - return - - saved = fake_room_store.saved_results[-1] - if saved.champion_name not in room.horses: - await test_simulate_race_cmd.send("完全模拟失败:赛史冠军不在参赛马匹中") - return - if not saved.point_changes: - await test_simulate_race_cmd.send("完全模拟失败:赛史未记录积分变化") - return - if not saved.point_change_summaries: - await test_simulate_race_cmd.send("完全模拟失败:赛史未记录积分变化总结") - return - - champion_owner_id = room.horses[saved.champion_name].owner_id - reward_champion_calls = [c for c in fake_points_service.calls if c[0] == "reward_champion"] - if not reward_champion_calls or reward_champion_calls[0][1]["user_id"] != champion_owner_id: - await test_simulate_race_cmd.send("完全模拟失败:未正确发放冠军奖励(内存记录)") - return - - participant_calls = [c for c in fake_points_service.calls if c[0] == "reward_participant"] - if len(participant_calls) != len(room.horses): - await test_simulate_race_cmd.send("完全模拟失败:参赛奖励次数不匹配(内存记录)") - return - - await test_simulate_race_cmd.send( - "\n".join( - [ - "完全模拟赛马完成(无真实积分/数据库副作用)", - f"参赛马匹:{', '.join(horse_names)}", - f"冠军:{saved.champion_name}(马主:{saved.champion_owner})", - f"总回合:{saved.duration_ticks}", - f"消息条数:{len(messages)}(开赛/进度/结算均已覆盖)", - f"结算调用:{len(fake_points_service.calls)}(冠军/参赛/下注派奖)", - f"积分变化用户数:{len(saved.point_changes)}", - f"过程展示:{'开启' if stream_progress else '关闭'}", - ] - ) - ) - return - except asyncio.TimeoutError: - ticks = room.tick_count if room else 0 - await test_simulate_race_cmd.send(f"完全模拟失败:超时未完成(当前回合:{ticks})") - except asyncio.CancelledError: - ticks = room.tick_count if room else 0 - await test_simulate_race_cmd.send(f"完全模拟失败:任务被取消(当前回合:{ticks})") - except Exception as e: - tail = "\n".join(traceback.format_exc().splitlines()[-8:]) - await test_simulate_race_cmd.send(f"完全模拟异常:{type(e).__name__}: {e}\n{tail}") - finally: - if start_task and not start_task.done(): - start_task.cancel() - commands_mod.room_store = original_room_store - commands_mod.points_service = original_points_service - commands_mod.message_service = original_message_service - commands_mod.config.RACE_TICK_INTERVAL = original_tick_interval - commands_mod.race_engine.stop_race = original_stop_race - commands_mod._send_to_scope = original_send_to_scope +from nonebot import on_command +from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, PrivateMessageEvent + +from . import plugin_config as config +from .commands import get_scope, check_access, race_engine +from .models import Horse, HorseState, RoomState, Bet, RaceResult + +import asyncio +import random +from datetime import datetime +import traceback + +from . import commands as commands_mod + + +async def check_tester(event: Event) -> bool: + """Check if user is a tester.""" + if not config.TEST_MODE: + return False + return event.user_id in config.TESTERS + + +test_reset_points_cmd = on_command("测试重置积分", priority=5) + + +@test_reset_points_cmd.handle() +async def handle_test_reset_points(bot: Bot, event: Event): + """Reset user points to 1000 for testing.""" + if not await check_tester(event): + await test_reset_points_cmd.finish("权限不足") + return + + success, _ = await commands_mod.points_service.set_points(event.user_id, 1000, "测试重置积分") + if success: + await test_reset_points_cmd.finish("积分已重置为1000") + else: + await test_reset_points_cmd.finish("重置失败") + + +test_set_points_cmd = on_command("测试设置积分", priority=5) + + +@test_set_points_cmd.handle() +async def handle_test_set_points(bot: Bot, event: Event): + """Set user points for testing.""" + if not await check_tester(event): + await test_set_points_cmd.finish("权限不足") + return + + # Get the message text and extract amount + msg = str(event.get_message()).strip() + # Remove command prefix + parts = msg.split() + + if len(parts) < 2: + await test_set_points_cmd.finish("请使用: /测试设置积分 <金额>") + return + + try: + amount = int(parts[1]) + if amount < 0: + await test_set_points_cmd.finish("金额必须为非负数") + return + except ValueError: + await test_set_points_cmd.finish("金额必须是整数") + return + + success, _ = await commands_mod.points_service.set_points(event.user_id, amount, f"测试设置积分为{amount}") + if success: + await test_set_points_cmd.finish(f"积分已设置为 {amount}") + else: + await test_set_points_cmd.finish("设置失败") + + +test_query_points_cmd = on_command("测试查询积分", priority=5) + + +@test_query_points_cmd.handle() +async def handle_test_query_points(bot: Bot, event: Event): + """Query user points for testing.""" + if not await check_tester(event): + await test_query_points_cmd.finish("权限不足") + return + + balance = await commands_mod.points_service.get_balance(event.user_id) + await test_query_points_cmd.finish(f"当前积分: {balance}") + + +test_clear_room_cmd = on_command("测试清空房间", priority=5) + + +@test_clear_room_cmd.handle() +async def handle_test_clear_room(bot: Bot, event: Event): + """Clear test room.""" + if not await check_tester(event): + await test_clear_room_cmd.finish("权限不足") + return + + scope = get_scope(event) + commands_mod.room_store.delete_room(scope) + await test_clear_room_cmd.finish("房间已清空") + + +test_force_start_cmd = on_command("测试强制开赛", priority=5) + + +@test_force_start_cmd.handle() +async def handle_test_force_start(bot: Bot, event: Event): + """Force start race for testing.""" + if not await check_tester(event): + await test_force_start_cmd.finish("权限不足") + return + + await test_force_start_cmd.finish("测试强制开赛命令") + + +def _generate_random_horse_names(count: int) -> list[str]: + prefixes = ["赤焰", "踏雪", "追风", "流星", "疾电", "破晓", "青岚", "玄影", "星尘", "霜刃", "烈阳", "苍穹"] + cores = ["奔", "跃", "影", "翼", "刃", "雷", "岚", "焰", "星", "雪", "风", "光"] + suffixes = ["号", "骑", "王", "将", "卫", "客", "影", "者", "马", "军"] + + names: set[str] = set() + attempts = 0 + while len(names) < count and attempts < 500: + attempts += 1 + name = f"{random.choice(prefixes)}{random.choice(cores)}{random.choice(suffixes)}" + if len(name) > 10: + name = name[:10] + names.add(name) + + while len(names) < count: + names.add(f"测试马{len(names) + 1}") + + return list(names)[:count] + + +test_simulate_race_cmd = on_command("测试模拟赛马", aliases={"测试模拟"}, priority=1, block=True) + + +class _FakeBot: + def __init__(self): + self.messages: list[dict] = [] + self._next_message_id = 1 + + async def send_msg(self, **kwargs): + self.messages.append(dict(kwargs)) + message_id = self._next_message_id + self._next_message_id += 1 + return {"message_id": message_id} + + async def delete_msg(self, message_id: int): + # Simply record the deletion if needed, or do nothing + return + + +class _InMemoryRoomStore: + def __init__(self): + self.rooms: dict[str, "commands_mod.Room"] = {} + self.saved_results: list[RaceResult] = [] + + def get_room(self, scope: str): + return self.rooms.get(scope) + + def create_room(self, scope: str): + room = commands_mod.Room(scope=scope) + self.rooms[scope] = room + return room + + def delete_room(self, scope: str): + if scope in self.rooms: + del self.rooms[scope] + + def save_race_result(self, result: RaceResult): + self.saved_results.append(result) + + +class _InMemoryPointsService: + def __init__(self): + self.calls: list[tuple[str, dict]] = [] + + async def reward_champion(self, user_id: str): + self.calls.append(("reward_champion", {"user_id": user_id})) + return True, 0 + + async def reward_participant(self, user_id: str): + self.calls.append(("reward_participant", {"user_id": user_id})) + return True, 0 + + async def payout_winnings(self, user_id: str, amount: int, odds: float): + self.calls.append(("payout_winnings", {"user_id": user_id, "amount": amount, "odds": odds})) + return True, 0 + + async def refund_bet_points(self, user_id: str, amount: int, reason: str = "比赛中断退还"): + self.calls.append(("refund_bet_points", {"user_id": user_id, "amount": amount, "reason": reason})) + return True, 0 + + async def get_balance(self, user_id: str) -> int: + self.calls.append(("get_balance", {"user_id": user_id})) + return 8888 + + +class _NoopMessageService: + def __init__(self): + self.last_messages: dict[str, dict[str, str]] = {} + + def clear_pending_recalls(self, scope: str): + if scope in self.last_messages: + del self.last_messages[scope] + + async def send_with_recall(self, bot, scope, message_type, message): + # Support basic recall for race_update to avoid flooding during simulation + if message_type == "race_update": + await self.recall_previous_of_type(bot, scope, "race_update") + + is_group = scope.startswith("group_") + result = await bot.send_msg( + message_type="group" if is_group else "private", + group_id=int(scope.split("_", 1)[1]) if is_group else None, + user_id=int(scope.split("_", 1)[1]) if not is_group else None, + message=message, + ) + + if scope not in self.last_messages: + self.last_messages[scope] = {} + + if isinstance(result, dict) and "message_id" in result: + self.last_messages[scope][message_type] = result["message_id"] + + return "fake_msg_id" + + async def recall_previous_of_type(self, bot, scope, message_type): + if scope in self.last_messages and message_type in self.last_messages[scope]: + msg_id = self.last_messages[scope][message_type] + try: + await bot.delete_msg(message_id=msg_id) + except Exception: + pass + del self.last_messages[scope][message_type] + + +@test_simulate_race_cmd.handle() +async def handle_test_simulate_race(bot: Bot, event: Event): + if not await check_tester(event): + await test_simulate_race_cmd.send("权限不足") + return + + await test_simulate_race_cmd.send("收到:测试模拟赛马,开始执行完全模拟(无真实积分/数据库副作用)") + + raw_msg = str(event.get_message()).strip() + stream_progress = ("展示" in raw_msg) or ("实时" in raw_msg) or ("慢" in raw_msg) + + scope = get_scope(event) + try: + race_engine.stop_race(scope) + await commands_mod.room_store.delete_room(scope) + except Exception: + pass + original_room_store = commands_mod.room_store + original_points_service = commands_mod.points_service + original_message_service = commands_mod.message_service + original_tick_interval = commands_mod.config.RACE_TICK_INTERVAL + original_stop_race = commands_mod.race_engine.stop_race + original_send_to_scope = commands_mod._send_to_scope + + + fake_room_store = _InMemoryRoomStore() + fake_points_service = _InMemoryPointsService() + fake_message_service = _NoopMessageService() + fake_bot = _FakeBot() + + start_task: asyncio.Task | None = None + room = None + + try: + await test_simulate_race_cmd.send("阶段:构建沙盒环境与参赛数据") + commands_mod.room_store = fake_room_store + commands_mod.points_service = fake_points_service + commands_mod.message_service = fake_message_service + commands_mod.config.RACE_TICK_INTERVAL = 1 if stream_progress else 0 + commands_mod.race_engine.stop_race = lambda _scope: commands_mod.race_engine.active_tasks.pop(_scope, None) + + progress_count = 0 + max_progress = 30 + + async def _test_send_to_scope(_bot: Bot, _scope: str, message: str, *args, **kwargs): + nonlocal progress_count + await fake_bot.send_msg(message_type="private", user_id=event.user_id, message=message) + if not stream_progress: + return + + if message.startswith("【第") and "回合】" in message: + progress_count += 1 + if progress_count > max_progress: + return + await original_send_to_scope(bot, scope, message, *args, **kwargs) + + commands_mod._send_to_scope = _test_send_to_scope + + room = fake_room_store.create_room(scope) + horse_names = _generate_random_horse_names(8) + for idx, horse_name in enumerate(horse_names, start=1): + owner_id = f"sim_user_{idx}" + room.horses[horse_name] = Horse(owner_id=owner_id, name=horse_name, index=idx, state=HorseState.RACING) + room.next_horse_index = len(horse_names) + 1 + + bet_amount = max(commands_mod.config.MIN_BET, 10) + room.bets.append(Bet(user_id="bettor_1", horse_name=horse_names[0], amount=bet_amount)) + room.bets.append(Bet(user_id="bettor_2", horse_name=horse_names[1], amount=bet_amount * 2)) + + room.state = RoomState.WAITING + + for horse in room.horses.values(): + horse.state = HorseState.RACING + + await test_simulate_race_cmd.send("阶段:执行赛程(后台任务)") + start_task = asyncio.create_task(commands_mod.run_race_with_settlement(fake_bot, room, scope)) + commands_mod.race_engine.register_task(scope, start_task) + await asyncio.wait_for(start_task, timeout=180 if stream_progress else 30) + + messages = [str(m.get("message", "")) for m in fake_bot.messages] + if not messages: + await test_simulate_race_cmd.send("完全模拟失败:未捕获到任何消息") + return + + if not any("比赛开始!" in msg for msg in messages): + await test_simulate_race_cmd.send("完全模拟失败:未发送开赛消息") + return + + # Look for the start message to verify horse names + start_msg = next((msg for msg in messages if "比赛开始!" in msg), "") + for idx, horse_name in enumerate(horse_names, start=1): + if f"{idx:02d}号 {horse_name}" not in start_msg: + await test_simulate_race_cmd.send(f"完全模拟失败:开赛名单中未找到 {idx:02d}号 {horse_name}") + return + + progress_messages = [msg for msg in messages if "【第" in msg and "回合】" in msg] + if not progress_messages: + await test_simulate_race_cmd.send("完全模拟失败:未发送回合进度消息") + return + + # Check first progress message format + progress_lines = [line for line in progress_messages[0].splitlines() if "|" in line] + if len(progress_lines) != len(horse_names): + await test_simulate_race_cmd.send("完全模拟失败:回合进度展示条目数量不匹配") + return + + if not any("比赛结束!冠军:" in msg for msg in messages): + await test_simulate_race_cmd.send("完全模拟失败:未发送结束结算消息") + return + + if not any("积分变化:" in msg for msg in messages): + await test_simulate_race_cmd.send("完全模拟失败:未发送积分变化总结") + return + + if not fake_room_store.saved_results: + await test_simulate_race_cmd.send("完全模拟失败:未写入赛史结果(内存)") + return + + saved = fake_room_store.saved_results[-1] + if saved.champion_name not in room.horses: + await test_simulate_race_cmd.send("完全模拟失败:赛史冠军不在参赛马匹中") + return + if not saved.point_changes: + await test_simulate_race_cmd.send("完全模拟失败:赛史未记录积分变化") + return + if not saved.point_change_summaries: + await test_simulate_race_cmd.send("完全模拟失败:赛史未记录积分变化总结") + return + + champion_owner_id = room.horses[saved.champion_name].owner_id + reward_champion_calls = [c for c in fake_points_service.calls if c[0] == "reward_champion"] + if not reward_champion_calls or reward_champion_calls[0][1]["user_id"] != champion_owner_id: + await test_simulate_race_cmd.send("完全模拟失败:未正确发放冠军奖励(内存记录)") + return + + participant_calls = [c for c in fake_points_service.calls if c[0] == "reward_participant"] + if len(participant_calls) != len(room.horses): + await test_simulate_race_cmd.send("完全模拟失败:参赛奖励次数不匹配(内存记录)") + return + + await test_simulate_race_cmd.send( + "\n".join( + [ + "完全模拟赛马完成(无真实积分/数据库副作用)", + f"参赛马匹:{', '.join(horse_names)}", + f"冠军:{saved.champion_name}(马主:{saved.champion_owner})", + f"总回合:{saved.duration_ticks}", + f"消息条数:{len(messages)}(开赛/进度/结算均已覆盖)", + f"结算调用:{len(fake_points_service.calls)}(冠军/参赛/下注派奖)", + f"积分变化用户数:{len(saved.point_changes)}", + f"过程展示:{'开启' if stream_progress else '关闭'}", + ] + ) + ) + return + except asyncio.TimeoutError: + ticks = room.tick_count if room else 0 + await test_simulate_race_cmd.send(f"完全模拟失败:超时未完成(当前回合:{ticks})") + except asyncio.CancelledError: + ticks = room.tick_count if room else 0 + await test_simulate_race_cmd.send(f"完全模拟失败:任务被取消(当前回合:{ticks})") + except Exception as e: + tail = "\n".join(traceback.format_exc().splitlines()[-8:]) + await test_simulate_race_cmd.send(f"完全模拟异常:{type(e).__name__}: {e}\n{tail}") + finally: + if start_task and not start_task.done(): + start_task.cancel() + commands_mod.room_store = original_room_store + commands_mod.points_service = original_points_service + commands_mod.message_service = original_message_service + commands_mod.config.RACE_TICK_INTERVAL = original_tick_interval + commands_mod.race_engine.stop_race = original_stop_race + commands_mod._send_to_scope = original_send_to_scope