diff --git a/danding_bot/plugins/group_horse_racing/commands.py b/danding_bot/plugins/group_horse_racing/commands.py index cd5c8d9..7570359 100644 --- a/danding_bot/plugins/group_horse_racing/commands.py +++ b/danding_bot/plugins/group_horse_racing/commands.py @@ -1,3 +1,7 @@ +import asyncio +import uuid +from datetime import datetime + from nonebot import on_command from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, PrivateMessageEvent @@ -5,7 +9,7 @@ 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 +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 @@ -40,6 +44,120 @@ async def check_access(bot: Bot, event: Event) -> bool: return False +def get_event_id(event: Event) -> str: + """Get user id as string from event.""" + return str(event.user_id) + + +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): + """Settle bets and rewards after race finishes.""" + champion = room.horses.get(room.champion_name) + if not champion: + return + + # Reward champion owner + await points_service.reward_champion(champion.owner_id) + + # Reward all participants + for horse in room.horses.values(): + if horse.owner_id != champion.owner_id: + await points_service.reward_participant(horse.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 + 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 room.horses.values()], + 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(), + ) + room_store.save_race_result(result) + + +async def run_race_with_settlement(bot: Bot, room: Room, scope: str): + """Run race and handle settlement + cleanup.""" + task = await race_engine.start_race(room) + + # Send start message + horse_list = "\n".join(f" {h.name} (主人: {h.owner_id})" for h in room.horses.values()) + try: + await bot.send_msg( + message_type="group" if scope.startswith("group_") else "private", + group_id=int(scope.split("_", 1)[1]) if scope.startswith("group_") else None, + user_id=int(scope.split("_", 1)[1]) if scope.startswith("test_") else None, + message=f"比赛开始!参赛马匹:\n{horse_list}", + ) + except Exception: + pass + + # Wait for race to finish + try: + await task + except asyncio.CancelledError: + room.state = RoomState.INTERRUPTED + # Refund all bets on interruption + for bet in room.bets: + await points_service.refund_bet_points(bet.user_id, bet.amount, "比赛中断退还") + return + + # Race finished - settle + await settle_race(room) + + # Build result message + odds = calculate_odds(room) + champion = room.horses.get(room.champion_name) + result_lines = [ + f"比赛结束!冠军:{room.champion_name} 🏆", + f"马主 {champion.owner_id if champion else '?'} 获得 {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)) + result_lines.append(f" {b.user_id} 下注 {b.amount} → 获得 {payout}") + + try: + await bot.send_msg( + message_type="group" if scope.startswith("group_") else "private", + group_id=int(scope.split("_", 1)[1]) if scope.startswith("group_") else None, + user_id=int(scope.split("_", 1)[1]) if scope.startswith("test_") else None, + message="\n".join(result_lines), + ) + except Exception: + pass + + # Cleanup + race_engine.stop_race(scope) + room_store.delete_room(scope) + message_service.clear_pending_recalls(scope) + + +# --- Commands --- + register_cmd = on_command("赛马报名", priority=5) @@ -50,7 +168,21 @@ async def handle_register(bot: Bot, event: Event): await register_cmd.finish("无权限访问此功能") return + # Parse horse name from message + msg = str(event.get_message()).strip() + parts = msg.split(None, 1) + horse_name = parts[1].strip() if len(parts) > 1 else "" + + if not horse_name: + await register_cmd.finish("请输入马匹名:/赛马报名 <马匹名>") + return + + if len(horse_name) > 10: + await register_cmd.finish("马匹名不能超过10个字符") + return + scope = get_scope(event) + user_id = get_event_id(event) lock = room_store.get_lock(scope) async with lock: @@ -58,11 +190,174 @@ async def handle_register(bot: Bot, event: Event): if not room: room = room_store.create_room(scope) - if len(room.horses) >= 8: - await register_cmd.finish("房间已满") + if room.state != RoomState.WAITING: + await register_cmd.finish("比赛正在进行中,无法报名") return - await register_cmd.finish("报名成功") + if len(room.horses) >= 8: + await register_cmd.finish("房间已满(最多8匹马)") + return + + if horse_name in room.horses: + await register_cmd.finish(f"马匹名 \"{horse_name}\" 已被使用") + return + + # Check if user already registered + for h in room.horses.values(): + if h.owner_id == user_id: + await register_cmd.finish("你已经报名了,不能重复报名") + return + + # Create horse + room.horses[horse_name] = Horse(owner_id=user_id, name=horse_name) + + # Reward participant (outside lock to avoid holding lock during API call) + await points_service.reward_participant(user_id) + + count = len(room.horses) + await register_cmd.finish(f"报名成功!马匹 \"{horse_name}\" 已加入比赛({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 + + # Find user's horse + user_horse = None + for name, horse in room.horses.items(): + if horse.owner_id == user_id: + user_horse = name + break + + if not user_horse: + await cancel_cmd.finish("你还没有报名") + return + + # Refund bets on this horse + bets_to_refund = [b for b in room.bets if b.horse_name == user_horse] + 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] + + # Remove horse + del room.horses[user_horse] + + await cancel_cmd.finish(f"已取消报名,马匹 \"{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 + + # Parse arguments: /赛马下注 <马匹名> <金额> + msg = str(event.get_message()).strip() + parts = msg.split() + if len(parts) < 3: + await bet_cmd.finish("请使用:/赛马下注 <马匹名> <金额>") + return + + horse_name = 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 + + if horse_name not in room.horses: + await bet_cmd.finish(f"马匹 \"{horse_name}\" 不存在") + return + + # Can't bet on your own horse + if room.horses[horse_name].owner_id == user_id: + await bet_cmd.finish("不能给自己的马下注") + return + + # Deduct points first + success, balance = await points_service.spend_bet_points(user_id, amount, f"下注 {horse_name}") + if not success: + await bet_cmd.finish(f"积分不足(当前余额:{balance})") + return + + # Record bet + room.bets.append(Bet(user_id=user_id, horse_name=horse_name, amount=amount)) + + odds = calculate_odds(room) + await bet_cmd.finish(f"下注成功!{horse_name} {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 name, odd in odds.items(): + horse_bet = sum(b.amount for b in room.bets if b.horse_name == name) + lines.append(f" {name} - {odd:.2f}倍 (总下注: {horse_bet})") + lines.append(f"总下注池: {total_bet}") + + await odds_cmd.finish("\n".join(lines)) start_cmd = on_command("赛马开赛", priority=5) @@ -81,15 +376,25 @@ async def handle_start(bot: Bot, event: Event): async with lock: room = room_store.get_room(scope) if not room: - await start_cmd.finish("房间不存在") + 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 - await race_engine.start_race(room) - await start_cmd.finish("比赛开始!") + # Set all horses to racing state + for horse in room.horses.values(): + horse.state = HorseState.RACING + + await start_cmd.finish("比赛开始!") + + # Run race in background (outside command handler) + asyncio.create_task(run_race_with_settlement(bot, room, scope)) help_cmd = on_command("赛马帮助", priority=5) @@ -98,13 +403,11 @@ help_cmd = on_command("赛马帮助", priority=5) @help_cmd.handle() async def handle_help(bot: Bot, event: Event): """Handle help command.""" - help_text = """ -赛马命令帮助: -/赛马报名 <马匹名> - 报名参赛 -/赛马取消报名 - 取消报名 -/赛马下注 <马匹名> <金额> - 下注 -/赛马开赛 - 开始比赛 -/赛马赔率 - 查看赔率 -/赛马帮助 - 显示此帮助 - """ + help_text = """赛马命令帮助: +/赛马报名 <马匹名> - 报名参赛(获得50积分奖励) +/赛马取消报名 - 取消报名并退还下注 +/赛马下注 <马匹名> <金额> - 下注(不能给自己的马下注) +/赛马赔率 - 查看当前赔率 +/赛马开赛 - 开始比赛(至少2匹马) +/赛马帮助 - 显示此帮助""" await help_cmd.finish(help_text)