From fe081f43cf25f7cc7fe6348b05639aac28776fee Mon Sep 17 00:00:00 2001 From: "Mr.Xia" <1424473282@qq.com> Date: Sat, 2 May 2026 14:33:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(race):=20=E4=BB=A3=E7=A0=81=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=20+=20commands?= =?UTF-8?q?=E5=8C=85=E6=8B=86=E5=88=86=20+=20=E8=B5=9B=E9=A9=AC=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: bet.py赔率计算移入锁内防竞态 - P1: config.py TESTERS解析失败添加warning日志 - P2: 新增赛马取消命令(积分退还/任务取消/状态重置) - P3: bet.py清理未使用的_send_to_scope导入 - 将commands.py拆分为commands/包(access/bet/help/race/register) - OpenSpec变更提案: fix-race-conditions-and-logs --- .../plugins/group_horse_racing/__init__.py | 73 +- .../plugins/group_horse_racing/commands.py | 707 ------------------ .../group_horse_racing/commands/__init__.py | 379 ++++++++++ .../group_horse_racing/commands/access.py | 31 + .../group_horse_racing/commands/bet.py | 187 +++++ .../group_horse_racing/commands/help.py | 43 ++ .../group_horse_racing/commands/race.py | 162 ++++ .../group_horse_racing/commands/register.py | 74 ++ .../plugins/group_horse_racing/config.py | 150 ++-- .../plugins/group_horse_racing/race_engine.py | 165 ++-- .../plugins/group_horse_racing/room_store.py | 241 +++--- 11 files changed, 1229 insertions(+), 983 deletions(-) delete mode 100644 danding_bot/plugins/group_horse_racing/commands.py create mode 100644 danding_bot/plugins/group_horse_racing/commands/__init__.py create mode 100644 danding_bot/plugins/group_horse_racing/commands/access.py create mode 100644 danding_bot/plugins/group_horse_racing/commands/bet.py create mode 100644 danding_bot/plugins/group_horse_racing/commands/help.py create mode 100644 danding_bot/plugins/group_horse_racing/commands/race.py create mode 100644 danding_bot/plugins/group_horse_racing/commands/register.py diff --git a/danding_bot/plugins/group_horse_racing/__init__.py b/danding_bot/plugins/group_horse_racing/__init__.py index 3797823..4f08706 100644 --- a/danding_bot/plugins/group_horse_racing/__init__.py +++ b/danding_bot/plugins/group_horse_racing/__init__.py @@ -1,30 +1,43 @@ -from nonebot import require, get_driver -from nonebot.plugin import PluginMetadata - -from .config import Config - -require("danding_bot.plugins.danding_points") - -__plugin_meta__ = PluginMetadata( - name="Group Horse Racing", - description="Group horse racing plugin with betting and points integration", - usage="Use /赛马 commands for horse racing gameplay", - type="application", - config=Config, - extra={ - "required_plugins": ["danding_bot.plugins.danding_points"], - }, -) - -# 从 NoneBot driver config 中提取本插件配置(去掉 GROUP_HORSE_RACING_ 前缀) -_nb_config = get_driver().config -_raw = _nb_config.model_dump() if hasattr(_nb_config, "model_dump") else _nb_config.dict() -_prefix = "group_horse_racing_" - -plugin_config = Config(**{ - k[len(_prefix):].upper(): v - for k, v in _raw.items() - if k.startswith(_prefix) -}) - -from . import commands, test_commands # noqa: F401, E402 +from nonebot import require, get_driver +from nonebot.plugin import PluginMetadata + +from .config import Config + +require("danding_bot.plugins.danding_points") + +__plugin_meta__ = PluginMetadata( + name="Group Horse Racing", + description="Group horse racing plugin with betting and points integration", + usage="Use /赛马 commands for horse racing gameplay", + type="application", + config=Config, + extra={ + "required_plugins": ["danding_bot.plugins.danding_points"], + }, +) + +# 从 NoneBot driver config 中提取本插件配置(去掉 GROUP_HORSE_RACING_ 前缀) +_nb_config = get_driver().config +_raw = _nb_config.model_dump() if hasattr(_nb_config, "model_dump") else _nb_config.dict() +_prefix = "group_horse_racing_" + +plugin_config = Config(**{ + k[len(_prefix):].upper(): v + for k, v in _raw.items() + if k.startswith(_prefix) +}) + +from . import commands, test_commands # noqa: F401, E402 + +# Register lifecycle hooks for room persistence +from .room_store import room_store + +_driver = get_driver() + +@_driver.on_startup +async def _on_startup(): + await room_store.load_rooms() + +@_driver.on_shutdown +async def _on_shutdown(): + await room_store.close() diff --git a/danding_bot/plugins/group_horse_racing/commands.py b/danding_bot/plugins/group_horse_racing/commands.py deleted file mode 100644 index 0f2d030..0000000 --- a/danding_bot/plugins/group_horse_racing/commands.py +++ /dev/null @@ -1,707 +0,0 @@ -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) -> 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 - - # 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, odds - - -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 (returns (result, odds) or None) - settlement = await settle_race(room) - result = settlement[0] if settlement else None - odds = settlement[1] if settlement else {} - - # 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) - - 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) - - -cancel_bet_cmd = on_command("赛马取消下注", priority=5) - - -@cancel_bet_cmd.handle() -async def handle_cancel_bet(bot: Bot, event: Event): - """Handle cancel bet - refund all bets placed by the user in current room.""" - if not await check_access(bot, event): - await cancel_bet_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_bet_cmd.finish("房间不存在") - return - - if room.state != RoomState.WAITING: - await cancel_bet_cmd.finish("比赛已开始,无法取消下注") - return - - user_bets = [b for b in room.bets if b.user_id == user_id] - if not user_bets: - await cancel_bet_cmd.finish("你还没有下注") - return - - total_refund = 0 - refund_errors = [] - for bet in user_bets: - try: - await points_service.refund_bet_points(bet.user_id, bet.amount, "取消下注退还") - total_refund += bet.amount - except Exception as e: - logger.error(f"退还下注失败 user={bet.user_id} amount={bet.amount}: {e}") - refund_errors.append(bet) - - # 只移除已成功退还的下注 - if refund_errors: - failed_amount = sum(b.amount for b in refund_errors) - room.bets = [b for b in room.bets if b.user_id != user_id or b in refund_errors] - await cancel_bet_cmd.finish(f"退还部分失败:成功退还 {total_refund} 积分,{len(refund_errors)} 笔退还失败({failed_amount} 积分),请联系管理员") - return - else: - room.bets = [b for b in room.bets if b.user_id != user_id] - - await cancel_bet_cmd.finish(f"已取消 {len(user_bets)} 笔下注,退还 {total_refund} 积分") - - -@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)) - - -race_list_cmd = on_command("赛马列表", priority=5) - - -@race_list_cmd.handle() -async def handle_race_list(bot: Bot, event: Event): - """显示当前房间所有报名马匹信息。""" - if not await check_access(bot, event): - await race_list_cmd.finish("无权限访问此功能") - return - - scope = get_scope(event) - room = room_store.get_room(scope) - if not room or not room.horses: - await race_list_cmd.finish("暂无报名马匹") - return - - lines = ["🏇 当前报名马匹:"] - for horse in _get_horses_in_order(room): - owner_display = await _get_user_name(bot, scope, horse.owner_id) - lines.append(f" {horse.index}. {horse.name} - 主人: {owner_display}") - lines.append(f"\n共 {len(room.horses)} 匹马") - - await race_list_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 - only participants or admins can start.""" - if not await check_access(bot, event): - await start_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 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 - - # 开赛权限限制:仅参赛者或群管理员可手动开赛(满8匹自动开赛不受影响) - is_participant = user_id in [h.owner_id for h in room.horses.values()] - is_admin = False - if isinstance(event, GroupMessageEvent): - try: - member_info = await bot.get_group_member_info( - group_id=event.group_id, - user_id=int(user_id) - ) - role = member_info.get("role", "") - is_admin = role in ("admin", "owner") - except Exception: - pass - - if not is_participant and not is_admin: - await start_cmd.finish("只有参赛者或群管理员可以开赛") - 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.PARTICIPANT_REWARD} 积分 -• 冠军马主:获得 {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/commands/__init__.py b/danding_bot/plugins/group_horse_racing/commands/__init__.py new file mode 100644 index 0000000..6e0c360 --- /dev/null +++ b/danding_bot/plugins/group_horse_racing/commands/__init__.py @@ -0,0 +1,379 @@ +import logging + +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 + +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 _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 _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. + + Args: + critical: If True, retry once on failure, then fallback to plain text. + """ + 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: + # Retry once with plain text + 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 + + # 1. Collect all user IDs involved + user_ids = set() + for horse in room.horses.values(): + user_ids.add(horse.owner_id) + for horse in room.horses.values(): + for bet in horse.bets: + user_ids.add(bet.user_id) + + # 2. Take balance snapshot BEFORE settlements + pre_balances = {} + for uid in user_ids: + balance = await points_service.get_balance(uid) + pre_balances[uid] = balance if balance is not None else 0 + + # 3. Execute all reward/payout operations + participant_points = config.PARTICIPANT_REWARD + for horse in room.horses.values(): + ret, code = await points_service.reward_participant(horse.owner_id, participant_points) + if not ret and code != POINTS_ERR_CODE_DUPLICATE: + logger.warning(f"reward_participant failed for {horse.owner_id}: code={code}") + + champion_points = config.CHAMPION_REWARD + ret, code = await points_service.reward_champion(champion.owner_id, champion_points) + if not ret and code != POINTS_ERR_CODE_DUPLICATE: + logger.warning(f"reward_champion failed for {champion.owner_id}: code={code}") + + all_bets = [] + for horse_name, horse in room.horses.items(): + all_bets.extend(horse.bets) + + total_bet = sum(bet.amount for bet in all_bets) + if total_bet == 0: + odds = {} + else: + odds = {} + for horse_name, horse in room.horses.items(): + horse_bet = sum(bet.amount for bet in horse.bets) + if horse_bet == 0: + odds[horse_name] = config.MAX_ODDS + else: + odds[horse_name] = max(config.MIN_ODDS, total_bet / horse_bet) + + champion_bets = room.horses[room.champion_name].bets + for bet in champion_bets: + win_amount = int(bet.amount * odds[room.champion_name]) + ret, code = await points_service.payout_winnings(bet.user_id, win_amount) + if not ret and code != POINTS_ERR_CODE_DUPLICATE: + logger.warning(f"payout_winnings failed for {bet.user_id}: code={code}") + + # 4. Take post-settlement snapshot and compute actual deltas + post_balances = {} + for uid in user_ids: + balance = await points_service.get_balance(uid) + post_balances[uid] = balance if balance is not None else 0 + + point_changes = {} + for uid in user_ids: + delta = post_balances[uid] - pre_balances[uid] + if delta != 0: + point_changes[uid] = delta + + result = RaceResult( + champion_name=room.champion_name, + finishing_order=[name for name in room.finishing_order if name in room.horses], + point_changes=point_changes # actual deltas + ) + 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") + + # 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 (returns (result, odds) or None) + settlement = await settle_race(room) + result = settlement[0] if settlement else None + odds = settlement[1] if settlement else {} + + # 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) + + 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) + + + + +# Re-export public names for external callers (test_commands, etc.) +from .access import get_scope, check_access +from .register import handle_register +from .bet import handle_cancel, handle_cancel_bet, handle_bet, handle_odds +from .race import handle_race_list, handle_start +from .help import handle_help diff --git a/danding_bot/plugins/group_horse_racing/commands/access.py b/danding_bot/plugins/group_horse_racing/commands/access.py new file mode 100644 index 0000000..a9cbb61 --- /dev/null +++ b/danding_bot/plugins/group_horse_racing/commands/access.py @@ -0,0 +1,31 @@ +from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, PrivateMessageEvent +from .. import plugin_config as config + +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) + diff --git a/danding_bot/plugins/group_horse_racing/commands/bet.py b/danding_bot/plugins/group_horse_racing/commands/bet.py new file mode 100644 index 0000000..03a0e02 --- /dev/null +++ b/danding_bot/plugins/group_horse_racing/commands/bet.py @@ -0,0 +1,187 @@ +from nonebot import on_command +from nonebot.adapters.onebot.v11 import Bot, Event +from . import ( + room_store, points_service, config, logger, + get_scope, check_access, get_event_id, + _resolve_horse_selector, _format_horse_label, + calculate_odds, +) + +@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) + + +cancel_bet_cmd = on_command("赛马取消下注", priority=5) + + +@cancel_bet_cmd.handle() +async def handle_cancel_bet(bot: Bot, event: Event): + """Handle cancel bet - refund all bets placed by the user in current room.""" + if not await check_access(bot, event): + await cancel_bet_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_bet_cmd.finish("房间不存在") + return + + if room.state != RoomState.WAITING: + await cancel_bet_cmd.finish("比赛已开始,无法取消下注") + return + + user_bets = [b for b in room.bets if b.user_id == user_id] + if not user_bets: + await cancel_bet_cmd.finish("你还没有下注") + return + + total_refund = 0 + refund_errors = [] + for bet in user_bets: + try: + await points_service.refund_bet_points(bet.user_id, bet.amount, "取消下注退还") + total_refund += bet.amount + except Exception as e: + logger.error(f"退还下注失败 user={bet.user_id} amount={bet.amount}: {e}") + refund_errors.append(bet) + + # 只移除已成功退还的下注 + if refund_errors: + failed_amount = sum(b.amount for b in refund_errors) + room.bets = [b for b in room.bets if b.user_id != user_id or b in refund_errors] + await cancel_bet_cmd.finish(f"退还部分失败:成功退还 {total_refund} 积分,{len(refund_errors)} 笔退还失败({failed_amount} 积分),请联系管理员") + return + else: + room.bets = [b for b in room.bets if b.user_id != user_id] + + await cancel_bet_cmd.finish(f"已取消 {len(user_bets)} 笔下注,退还 {total_refund} 积分") + + +@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)) + + +race_list_cmd = on_command("赛马列表", priority=5) + + diff --git a/danding_bot/plugins/group_horse_racing/commands/help.py b/danding_bot/plugins/group_horse_racing/commands/help.py new file mode 100644 index 0000000..6af3063 --- /dev/null +++ b/danding_bot/plugins/group_horse_racing/commands/help.py @@ -0,0 +1,43 @@ +from nonebot import on_command +from nonebot.adapters.onebot.v11 import Bot, Event +from . import config, logger, get_scope, check_access + +@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.PARTICIPANT_REWARD} 积分 +• 冠军马主:获得 {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/commands/race.py b/danding_bot/plugins/group_horse_racing/commands/race.py new file mode 100644 index 0000000..1ca8aee --- /dev/null +++ b/danding_bot/plugins/group_horse_racing/commands/race.py @@ -0,0 +1,162 @@ +import asyncio +from nonebot import on_command +from nonebot.adapters.onebot.v11 import Bot, Event +from . import ( + room_store, race_engine, config, logger, + get_scope, check_access, get_event_id, + _send_to_scope, _build_race_image_message, + run_race_with_settlement, points_service, +) +from ..models import RoomState, HorseState +from nonebot.adapters.onebot.v11 import GroupMessageEvent +from ..models import RoomState + +@race_list_cmd.handle() +async def handle_race_list(bot: Bot, event: Event): + """显示当前房间所有报名马匹信息。""" + if not await check_access(bot, event): + await race_list_cmd.finish("无权限访问此功能") + return + + scope = get_scope(event) + room = room_store.get_room(scope) + if not room or not room.horses: + await race_list_cmd.finish("暂无报名马匹") + return + + lines = ["🏇 当前报名马匹:"] + for horse in _get_horses_in_order(room): + owner_display = await _get_user_name(bot, scope, horse.owner_id) + lines.append(f" {horse.index}. {horse.name} - 主人: {owner_display}") + lines.append(f"\n共 {len(room.horses)} 匹马") + + await race_list_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 - only participants or admins can start.""" + if not await check_access(bot, event): + await start_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 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 + + # 开赛权限限制:仅参赛者或群管理员可手动开赛(满8匹自动开赛不受影响) + is_participant = user_id in [h.owner_id for h in room.horses.values()] + is_admin = False + if isinstance(event, GroupMessageEvent): + try: + member_info = await bot.get_group_member_info( + group_id=event.group_id, + user_id=int(user_id) + ) + role = member_info.get("role", "") + is_admin = role in ("admin", "owner") + except Exception: + pass + + if not is_participant and not is_admin: + await start_cmd.finish("只有参赛者或群管理员可以开赛") + 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) + + +cancel_race_cmd = on_command("赛马取消", priority=5) + + +@cancel_race_cmd.handle() +async def handle_cancel_race(bot: Bot, event: Event): + """取消当前进行的比赛,退还所有下注积分。仅参赛者或管理员可操作。""" + if not await check_access(bot, event): + await cancel_race_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_race_cmd.finish("房间不存在") + return + + if room.state != RoomState.RACING: + await cancel_race_cmd.finish("当前没有进行中的比赛") + return + + # 权限:只有参赛者或群管理员可以取消 + is_participant = user_id in [h.owner_id for h in room.horses.values()] + is_admin = False + if isinstance(event, GroupMessageEvent): + try: + member_info = await bot.get_group_member_info( + group_id=event.group_id, + user_id=int(user_id) + ) + role = member_info.get("role", "") + is_admin = role in ("admin", "owner") + except Exception: + pass + + if not is_participant and not is_admin: + await cancel_race_cmd.finish("只有参赛者或群管理员可以取消比赛") + return + + # 停止后台比赛任务 + race_engine.stop_race(scope) + + # 退还所有下注积分 + total_refund = 0 + for bet in room.bets[:]: # 遍历副本 + success, _ = await points_service.refund_bet_points( + bet.user_id, bet.amount, "比赛取退还下注" + ) + if success: + total_refund += bet.amount + + # 清空下注记录 + room.bets.clear() + + # 重置马匹状态为等待 + for horse in room.horses.values(): + horse.state = HorseState.WAITING + + # 重置房间状态 + room.state = RoomState.WAITING + room.tick_count = 0 + + await _send_to_scope(scope, f"🏇 比赛已取消!共退还 {total_refund} 积分。") + +help_cmd = on_command("赛马帮助", priority=5) + + diff --git a/danding_bot/plugins/group_horse_racing/commands/register.py b/danding_bot/plugins/group_horse_racing/commands/register.py new file mode 100644 index 0000000..f4c5871 --- /dev/null +++ b/danding_bot/plugins/group_horse_racing/commands/register.py @@ -0,0 +1,74 @@ +import asyncio +from nonebot import on_command +from nonebot.adapters.onebot.v11 import Bot, Event +from . import ( + room_store, race_engine, config, logger, + get_scope, check_access, get_event_id, + _find_user_horse, _find_duplicate_horse, _get_horses_in_order, + _format_horse_label, _send_to_scope, _build_race_image_message, + run_race_with_settlement, +) +from ..models import HorseState, RoomState + +@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 = _normalize_horse_name(parts[1]) 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) + + diff --git a/danding_bot/plugins/group_horse_racing/config.py b/danding_bot/plugins/group_horse_racing/config.py index b7439e0..697d54c 100644 --- a/danding_bot/plugins/group_horse_racing/config.py +++ b/danding_bot/plugins/group_horse_racing/config.py @@ -1,74 +1,76 @@ -from pydantic import Field, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict -import json - - -class Config(BaseSettings): - model_config = SettingsConfigDict( - extra="ignore", - env_prefix="GROUP_HORSE_RACING_", - ) - - # 测试模式配置 - TEST_MODE: bool = False - TESTERS: set[int] = Field(default_factory=set) - TEST_GROUPS: set[int] = Field(default_factory=set) - ALLOWED_GROUPS: set[int] = Field(default_factory=set) - - # 奖励配置 - PARTICIPANT_REWARD: int = 20 - CHAMPION_REWARD: int = 150 - MIN_BET: int = 10 - MIN_ODDS: float = 1.2 - RACE_DISTANCE: int = 100 - RACE_TICK_INTERVAL: int = 5 - RACE_RENDER_AS_IMAGE: bool = True - RACE_IMAGE_WIDTH: int = 900 - RACE_IMAGE_FONT_SIZE: int = 26 - RACE_IMAGE_PADDING: int = 28 - RACE_IMAGE_LINE_SPACING: float = 1.35 - - # 消息撤回配置 - MESSAGE_RECALL: dict[str, int] = Field( - default_factory=lambda: { - "race_update": 30, - "registration": 180, - "bet_confirm": 180, - "cancel_confirm": 60, - "error": 60, - "race_result": 0, - "leaderboard": 0, - "help": 0, - "odds_display": 0, - } - ) - - # 数据库配置 - RACE_DB_FILE: str = "data/group_horse_racing/race.db" - - @field_validator("TESTERS", "TEST_GROUPS", "ALLOWED_GROUPS", mode="before") - @classmethod - def parse_id_sets(cls, v): - """Parse ID sets from various formats.""" - if isinstance(v, set): - return v - if isinstance(v, str): - return cls._parse_id_set(v) - if isinstance(v, (list, tuple)): - return set(int(x) for x in v) - return v if isinstance(v, set) else set() - - @staticmethod - def _parse_id_set(v: str) -> set[int]: - """Parse ID sets from various formats.""" - try: - parsed = json.loads(v) - if isinstance(parsed, list): - return set(int(x) for x in parsed) - except (json.JSONDecodeError, ValueError, TypeError): - pass - try: - return set(int(x.strip()) for x in v.split(",") if x.strip()) - except ValueError: - pass - return set() +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +import json + + +class Config(BaseSettings): + model_config = SettingsConfigDict( + extra="ignore", + env_prefix="GROUP_HORSE_RACING_", + ) + + # 测试模式配置 + TEST_MODE: bool = False + TESTERS: set[int] = Field(default_factory=set) + TEST_GROUPS: set[int] = Field(default_factory=set) + ALLOWED_GROUPS: set[int] = Field(default_factory=set) + + # 奖励配置 + PARTICIPANT_REWARD: int = 20 + CHAMPION_REWARD: int = 150 + MIN_BET: int = 10 + MIN_ODDS: float = 1.2 + RACE_DISTANCE: int = 100 + RACE_TICK_INTERVAL: int = 5 + RACE_RENDER_AS_IMAGE: bool = True + RACE_IMAGE_WIDTH: int = 900 + RACE_IMAGE_FONT_SIZE: int = 26 + RACE_IMAGE_PADDING: int = 28 + RACE_IMAGE_LINE_SPACING: float = 1.35 + + # 消息撤回配置 + MESSAGE_RECALL: dict[str, int] = Field( + default_factory=lambda: { + "race_update": 30, + "registration": 180, + "bet_confirm": 180, + "cancel_confirm": 60, + "error": 60, + "race_result": 0, + "leaderboard": 0, + "help": 0, + "odds_display": 0, + } + ) + + # 数据库配置 + RACE_DB_FILE: str = "data/group_horse_racing/race.db" + + @field_validator("TESTERS", "TEST_GROUPS", "ALLOWED_GROUPS", mode="before") + @classmethod + def parse_id_sets(cls, v): + """Parse ID sets from various formats.""" + if isinstance(v, set): + return v + if isinstance(v, str): + return cls._parse_id_set(v) + if isinstance(v, (list, tuple)): + return set(int(x) for x in v) + return v if isinstance(v, set) else set() + + @staticmethod + def _parse_id_set(v: str) -> set[int]: + """Parse ID sets from various formats.""" + try: + parsed = json.loads(v) + if isinstance(parsed, list): + return set(int(x) for x in parsed) + except (json.JSONDecodeError, ValueError, TypeError) as e: + import logging + logging.getLogger(__name__).warning(f"TESTERS 解析失败: {v}, error: {e}") + pass + try: + return set(int(x.strip()) for x in v.split(",") if x.strip()) + except ValueError: + pass + return set() diff --git a/danding_bot/plugins/group_horse_racing/race_engine.py b/danding_bot/plugins/group_horse_racing/race_engine.py index bf20a63..585e5cc 100644 --- a/danding_bot/plugins/group_horse_racing/race_engine.py +++ b/danding_bot/plugins/group_horse_racing/race_engine.py @@ -1,80 +1,85 @@ -import asyncio -import random -from typing import Optional - -from .models import Room, RoomState, Horse, HorseState -from .config import Config - - -class RaceEngine: - def __init__(self, config: Config): - self.config = config - self.active_tasks: dict[str, asyncio.Task] = {} - - def tick(self, room: Room): - """Advance race by one tick. Returns list of finished horses.""" - room.tick_count += 1 - - for horse in room.horses.values(): - if horse.state == HorseState.RACING: - distance = max(0, random.gauss(10, 3)) - horse.position += distance - - finished_horses = [ - h for h in room.horses.values() - if h.position >= self.config.RACE_DISTANCE - ] - - return finished_horses - - def determine_champion(self, horses: list[Horse]) -> Horse: - """Determine champion from tied horses.""" - if len(horses) == 1: - return horses[0] - - while len(horses) > 1: - distances = [max(0, random.gauss(10, 3)) for _ in horses] - max_distance = max(distances) - horses = [h for h, d in zip(horses, distances) if d == max_distance] - - return horses[0] - - def register_task(self, scope: str, task: asyncio.Task): - """Register an active race task.""" - self.active_tasks[scope] = task - - def stop_race(self, scope: str): - """Stop race and cancel task.""" - if scope in self.active_tasks: - task = self.active_tasks[scope] - if not task.done(): - task.cancel() - del self.active_tasks[scope] - - def format_progress(self, room: Room) -> str: - """Format race progress as visual bar for each horse.""" - distance = self.config.RACE_DISTANCE - bar_width = 20 - lines = [f"【第{room.tick_count}回合】"] - - sorted_horses = sorted(room.horses.values(), key=lambda h: h.index) - # Calculate max display width for name alignment - # Each CJK/fullwidth char counts as 2, others as 1 - def _display_width(s: str) -> int: - w = 0 - for ch in s: - w += 2 if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or '\uff00' <= ch <= '\uffef' else 1 - return w - - max_name_width = max(_display_width(h.name) for h in sorted_horses) - - for horse in sorted_horses: - progress = min(horse.position / distance, 1.0) - filled = int(progress * bar_width) - bar = "█" * filled + "░" * (bar_width - filled) - # Pad with fullwidth spaces (1 fullwidth space = 2 columns = 1 CJK char width) - diff = max_name_width - _display_width(horse.name) - pad = "\u3000" * (diff // 2) + (" " if diff % 2 else "") - lines.append(f" {horse.index:02d}号 {horse.name}{pad} |{bar}| {horse.position:.1f}m") - - return "\n".join(lines) +import asyncio +import random +from typing import Optional + +from .models import Room, RoomState, Horse, HorseState +from .config import Config + + +class RaceEngine: + def __init__(self, config: Config): + self.config = config + self.active_tasks: dict[str, asyncio.Task] = {} + + def tick(self, room: Room): + """Advance race by one tick. Returns list of finished horses.""" + room.tick_count += 1 + + for horse in room.horses.values(): + if horse.state == HorseState.RACING: + distance = max(0.5, random.gauss(10, 3)) # Minimum 0.5m displacement + horse.position += distance + + finished_horses = [ + h for h in room.horses.values() + if h.position >= self.config.RACE_DISTANCE + ] + + return finished_horses + + MAX_ROUNDS = 100 # Safety valve + + def determine_champion(self, horses: list[Horse]) -> Horse: + """Determine champion from tied horses.""" + if len(horses) == 1: + return horses[0] + + for _ in range(self.MAX_ROUNDS): + distances = [max(0, random.gauss(10, 3)) for _ in horses] + max_distance = max(distances) + horses = [h for h, d in zip(horses, distances) if d == max_distance] + if len(horses) == 1: + return horses[0] + + # Fallback: random choice after max rounds + return random.choice(horses) + + def register_task(self, scope: str, task: asyncio.Task): + """Register an active race task.""" + self.active_tasks[scope] = task + + def stop_race(self, scope: str): + """Stop race and cancel task.""" + if scope in self.active_tasks: + task = self.active_tasks[scope] + if not task.done(): + task.cancel() + del self.active_tasks[scope] + + def format_progress(self, room: Room) -> str: + """Format race progress as visual bar for each horse.""" + distance = self.config.RACE_DISTANCE + bar_width = 20 + lines = [f"【第{room.tick_count}回合】"] + + sorted_horses = sorted(room.horses.values(), key=lambda h: h.index) + # Calculate max display width for name alignment + # Each CJK/fullwidth char counts as 2, others as 1 + def _display_width(s: str) -> int: + w = 0 + for ch in s: + w += 2 if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or '\uff00' <= ch <= '\uffef' else 1 + return w + + max_name_width = max(_display_width(h.name) for h in sorted_horses) + + for horse in sorted_horses: + progress = min(horse.position / distance, 1.0) + filled = int(progress * bar_width) + bar = "█" * filled + "░" * (bar_width - filled) + # Pad with fullwidth spaces (1 fullwidth space = 2 columns = 1 CJK char width) + diff = max_name_width - _display_width(horse.name) + pad = "\u3000" * (diff // 2) + (" " if diff % 2 else "") + lines.append(f" {horse.index:02d}号 {horse.name}{pad} |{bar}| {horse.position:.1f}m") + + return "\n".join(lines) diff --git a/danding_bot/plugins/group_horse_racing/room_store.py b/danding_bot/plugins/group_horse_racing/room_store.py index 063dcd6..1b51c20 100644 --- a/danding_bot/plugins/group_horse_racing/room_store.py +++ b/danding_bot/plugins/group_horse_racing/room_store.py @@ -17,66 +17,67 @@ class RoomStore: self.db_path = Path(config.RACE_DB_FILE) self.db_path.parent.mkdir(parents=True, exist_ok=True) self._initialized = False + self._db: Optional[aiosqlite.Connection] = None 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 '{}' - ) - """) + db = await self._get_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 user_horse_names ( - user_id TEXT PRIMARY KEY, - horse_name TEXT NOT NULL - ) - """) + 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 '{}'") - # 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() + 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): @@ -84,6 +85,62 @@ class RoomStore: if not self._initialized: await self._init_db() + async def _get_db(self) -> aiosqlite.Connection: + """Get or create lazy database connection with WAL mode.""" + if self._db is None: + self._db = await aiosqlite.connect(self.db_path) + await self._db.execute("PRAGMA journal_mode=WAL") + await self._db.commit() + return self._db + + async def close(self): + """Close database connection on shutdown.""" + if self._db is not None: + await self._db.close() + self._db = None + + async def load_rooms(self): + """Restore active rooms from DB snapshots on startup.""" + await self.ensure_initialized() + db = await self.__db if self._db else await self._get_db() + cursor = await db.execute( + "SELECT scope, state, created_at, horses, bets, champion_name, tick_count FROM room_snapshots" + ) + rows = await cursor.fetchall() + from .models import Horse, Bet + for row in rows: + scope, state_str, created_at, horses_json, bets_json, champion_name, tick_count = row + # Only restore rooms in WAITING state (not crashed mid-race) + if state_str != "WAITING": + await db.execute("DELETE FROM room_snapshots WHERE scope = ?", (scope,)) + continue + room = Room(scope=scope) + room.state = RoomState(state_str) + room.created_at = datetime.fromisoformat(created_at) + room.champion_name = champion_name or "" + room.tick_count = tick_count or 0 + # Restore horses + horses_data = json.loads(horses_json) + for name, h_data in horses_data.items(): + horse = Horse( + owner_id=h_data["owner_id"], + name=h_data["name"], + index=h_data["index"], + ) + horse.position = h_data.get("position", 0.0) + room.horses[name] = horse + # Restore bets + bets_data = json.loads(bets_json) + for b in bets_data: + room.bets.append(Bet( + user_id=b["user_id"], + horse_name=b["horse_name"], + amount=b["amount"], + )) + room.next_horse_index = max((h.index for h in room.horses.values()), default=0) + 1 + self.rooms[scope] = room + await db.commit() + def get_lock(self, scope: str) -> asyncio.Lock: """Get or create per-room lock.""" if scope not in self._locks: @@ -108,22 +165,22 @@ class RoomStore: 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 + db = await self._get_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() + db = await self._get_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.""" @@ -149,44 +206,44 @@ class RoomStore: for bet in room.bets ]) - async with aiosqlite.connect(self.db_path) as db: - await db.execute(""" - INSERT OR REPLACE INTO room_snapshots + db = await self._get_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() + 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 + db = await self._get_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() + 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()