diff --git a/danding_bot/plugins/group_horse_racing/commands.py b/danding_bot/plugins/group_horse_racing/commands.py index 5391567..c8193e4 100644 --- a/danding_bot/plugins/group_horse_racing/commands.py +++ b/danding_bot/plugins/group_horse_racing/commands.py @@ -84,6 +84,108 @@ def get_event_id(event: Event) -> str: 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] = {} + + 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 horse in room.horses.values(): + if champion and horse.owner_id != champion.owner_id: + point_changes[horse.owner_id] = point_changes.get(horse.owner_id, 0) + config.PARTICIPANT_REWARD + + for bet in room.bets: + if bet.horse_name == room.champion_name: + payout = int(bet.amount * odds.get(bet.horse_name, config.MIN_ODDS)) + point_changes[bet.user_id] = point_changes.get(bet.user_id, 0) + payout + + point_summaries = { + user_id: _describe_points_delta(delta) + for user_id, delta in point_changes.items() + } + return point_changes, point_summaries + + +def _format_point_change_lines(room: Room, point_changes: dict[str, int], point_summaries: 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)) + lines.append(f" {user_id} {delta:+d} 积分 · {summary}") + 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) @@ -98,11 +200,11 @@ def calculate_odds(room: Room) -> dict[str, float]: return odds -async def settle_race(room: Room): +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 + return None # Reward champion owner await points_service.reward_champion(champion.owner_id) @@ -119,17 +221,21 @@ async def settle_race(room: Room): 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 room.horses.values()], + 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): @@ -152,8 +258,7 @@ async def run_race_with_settlement(bot: Bot, room: Room, scope: str): """Run race with live progress updates and settlement.""" room.state = RoomState.RUNNING - # Send start message with horse list - horse_list = "\n".join(f" {h.name}" for h in room.horses.values()) + 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 loop with progress updates @@ -177,13 +282,12 @@ async def run_race_with_settlement(bot: Bot, room: Room, scope: str): return # Settle - await settle_race(room) + result = 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"比赛结束!冠军:{_format_horse_label(champion) if champion else 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] @@ -192,6 +296,8 @@ async def run_race_with_settlement(bot: Bot, room: Room, scope: str): 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}") + if result: + result_lines.extend(_format_point_change_lines(room, result.point_changes, result.point_change_summaries)) await _send_to_scope(bot, scope, "\n".join(result_lines)) @@ -213,13 +319,13 @@ 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 "" + 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: - await register_cmd.finish("请输入马匹名:/赛马报名 <马匹名>") + await register_cmd.finish("请输入马匹名:/赛马报名 <马匹名>。若你之前报过名,也可以直接发送 /赛马报名 复用上一次绑定的马名") return if len(horse_name) > 10: @@ -227,7 +333,6 @@ async def handle_register(bot: Bot, event: Event): return scope = get_scope(event) - user_id = get_event_id(event) lock = room_store.get_lock(scope) async with lock: @@ -243,21 +348,24 @@ async def handle_register(bot: Bot, event: Event): await register_cmd.finish("房间已满(最多8匹马)") return - if horse_name in room.horses: - await register_cmd.finish(f"马匹名 \"{horse_name}\" 已被使用") + duplicate_horse = _find_duplicate_horse(room, horse_name) + if duplicate_horse: + await register_cmd.finish(f"马匹名 \"{horse_name}\" 已被 {_format_horse_label(duplicate_horse)} 使用") return - # Check if user already registered - for h in room.horses.values(): - if h.owner_id == user_id: - await register_cmd.finish("你已经报名了,不能重复报名") - 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 - # Create horse - room.horses[horse_name] = Horse(owner_id=user_id, name=horse_name) + 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) - await register_cmd.finish(f"报名成功!马匹 \"{horse_name}\" 已加入比赛({count}/8)") + registered_horse = room.horses[horse_name] + await register_cmd.finish(f"报名成功!{_format_horse_label(registered_horse)} 已加入比赛({count}/8)") cancel_cmd = on_command("赛马取消报名", priority=5) @@ -284,27 +392,19 @@ async def handle_cancel(bot: Bot, event: Event): 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 - + user_horse = _find_user_horse(room, user_id) 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] + 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] + room.bets = [b for b in room.bets if b.horse_name != user_horse.name] - # Remove horse - del room.horses[user_horse] + del room.horses[user_horse.name] - await cancel_cmd.finish(f"已取消报名,马匹 \"{user_horse}\" 已退出") + await cancel_cmd.finish(f"已取消报名,{_format_horse_label(user_horse)} 已退出") bet_cmd = on_command("赛马下注", priority=5) @@ -317,14 +417,13 @@ async def handle_bet(bot: Bot, event: 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("请使用:/赛马下注 <马匹名> <金额>") + await bet_cmd.finish("请使用:/赛马下注 <序号|马匹名> <金额>") return - horse_name = parts[1] + horse_selector = parts[1] try: amount = int(parts[2]) except ValueError: @@ -349,26 +448,24 @@ async def handle_bet(bot: Bot, event: Event): await bet_cmd.finish("比赛正在进行中,无法下注") return - if horse_name not in room.horses: - await bet_cmd.finish(f"马匹 \"{horse_name}\" 不存在") + horse = _resolve_horse_selector(room, horse_selector) + if not horse: + await bet_cmd.finish(f"马匹序号/名称 \"{horse_selector}\" 不存在") return - # Can't bet on your own horse - if room.horses[horse_name].owner_id == user_id: + if horse.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}") + 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 - # Record bet - room.bets.append(Bet(user_id=user_id, horse_name=horse_name, amount=amount)) + 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})") + await bet_cmd.finish(f"下注成功!{_format_horse_label(horse)} {amount}积分(当前赔率:{odds.get(horse.name, config.MIN_ODDS):.2f})") odds_cmd = on_command("赛马赔率", priority=5) @@ -394,9 +491,10 @@ async def handle_odds(bot: Bot, event: Event): 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})") + 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)) @@ -449,7 +547,8 @@ async def handle_help(bot: Bot, event: Event): help_text = """赛马命令帮助: /赛马报名 <马匹名> - 报名参赛 /赛马取消报名 - 取消报名并退还下注 -/赛马下注 <马匹名> <金额> - 下注(不能给自己的马下注) +/赛马报名 - 复用上一次绑定的马名报名 +/赛马下注 <序号|马匹名> <金额> - 下注(不能给自己的马下注) /赛马赔率 - 查看当前赔率 /赛马开赛 - 开始比赛(至少2匹马) /赛马帮助 - 显示此帮助""" diff --git a/danding_bot/plugins/group_horse_racing/models.py b/danding_bot/plugins/group_horse_racing/models.py index 3988aef..75132f2 100644 --- a/danding_bot/plugins/group_horse_racing/models.py +++ b/danding_bot/plugins/group_horse_racing/models.py @@ -21,6 +21,7 @@ class HorseState(str, Enum): class Horse: owner_id: str name: str + index: int = 0 position: float = 0.0 state: HorseState = HorseState.READY @@ -41,6 +42,7 @@ class Room: bets: list[Bet] = field(default_factory=list) champion_name: Optional[str] = None tick_count: int = 0 + next_horse_index: int = 1 @dataclass @@ -53,3 +55,5 @@ class RaceResult: bet_distribution: dict[str, int] duration_ticks: int completed_at: datetime + point_changes: dict[str, int] = field(default_factory=dict) + point_change_summaries: dict[str, str] = field(default_factory=dict) diff --git a/danding_bot/plugins/group_horse_racing/race_engine.py b/danding_bot/plugins/group_horse_racing/race_engine.py index 85da2a7..8d57170 100644 --- a/danding_bot/plugins/group_horse_racing/race_engine.py +++ b/danding_bot/plugins/group_horse_racing/race_engine.py @@ -57,12 +57,11 @@ class RaceEngine: bar_width = 20 lines = [f"【第{room.tick_count}回合】"] - # Sort by position descending for readability - sorted_horses = sorted(room.horses.values(), key=lambda h: h.position, reverse=True) + sorted_horses = sorted(room.horses.values(), key=lambda h: h.index) for horse in sorted_horses: progress = min(horse.position / distance, 1.0) filled = int(progress * bar_width) bar = "█" * filled + "░" * (bar_width - filled) - lines.append(f" {horse.name:<6} |{bar}| {horse.position:.1f}m") + lines.append(f" {horse.index:02d}号 {horse.name} |{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 4bac2ff..c62fc4d 100644 --- a/danding_bot/plugins/group_horse_racing/room_store.py +++ b/danding_bot/plugins/group_horse_racing/room_store.py @@ -13,6 +13,7 @@ class RoomStore: self.config = config self.rooms: dict[str, Room] = {} self._locks: dict[str, asyncio.Lock] = {} + self._last_horse_names: dict[str, str] = {} self.db_path = Path(config.RACE_DB_FILE) self.db_path.parent.mkdir(parents=True, exist_ok=True) self._init_db() @@ -72,6 +73,12 @@ class RoomStore: if scope in self.rooms: del self.rooms[scope] + def get_last_horse_name(self, user_id: str) -> Optional[str]: + return self._last_horse_names.get(user_id) + + def set_last_horse_name(self, user_id: str, horse_name: str): + self._last_horse_names[user_id] = horse_name + def _save_snapshot(self, room: Room): """Save room snapshot to database.""" import json @@ -80,6 +87,7 @@ class RoomStore: name: { "owner_id": horse.owner_id, "name": horse.name, + "index": horse.index, "position": horse.position, "state": horse.state.value, } diff --git a/danding_bot/plugins/group_horse_racing/test_commands.py b/danding_bot/plugins/group_horse_racing/test_commands.py index 2f3ae75..baaf26d 100644 --- a/danding_bot/plugins/group_horse_racing/test_commands.py +++ b/danding_bot/plugins/group_horse_racing/test_commands.py @@ -258,7 +258,8 @@ async def handle_test_simulate_race(bot: Bot, event: Event): 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, state=HorseState.RACING) + 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)) @@ -282,15 +283,31 @@ async def handle_test_simulate_race(bot: Bot, event: Event): if "比赛开始!" not in messages[0]: await test_simulate_race_cmd.send("完全模拟失败:未发送开赛消息") return + for idx, horse_name in enumerate(horse_names, start=1): + if f"{idx:02d}号 {horse_name}" not in messages[0]: + await test_simulate_race_cmd.send("完全模拟失败:开赛名单未按序号展示") + return - if not any("【第" in msg and "回合】" in msg for msg in messages): + progress_messages = [msg for msg in messages if "【第" in msg and "回合】" in msg] + if not progress_messages: await test_simulate_race_cmd.send("完全模拟失败:未发送回合进度消息") return + progress_lines = progress_messages[0].splitlines()[1:] + if len(progress_lines) != len(horse_names): + await test_simulate_race_cmd.send("完全模拟失败:回合进度展示条目数量不匹配") + return + for idx, line in enumerate(progress_lines, start=1): + if not line.strip().startswith(f"{idx:02d}号 "): + await test_simulate_race_cmd.send("完全模拟失败:回合进度未按报名序号固定排序") + return result_msg = messages[-1] if "比赛结束!冠军:" not in result_msg: await test_simulate_race_cmd.send("完全模拟失败:未发送结束结算消息") return + if "积分变化:" not in result_msg: + await test_simulate_race_cmd.send("完全模拟失败:未发送积分变化总结") + return if not fake_room_store.saved_results: await test_simulate_race_cmd.send("完全模拟失败:未写入赛史结果(内存)") @@ -300,6 +317,12 @@ async def handle_test_simulate_race(bot: Bot, event: Event): 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"] @@ -321,6 +344,7 @@ async def handle_test_simulate_race(bot: Bot, event: Event): f"总回合:{saved.duration_ticks}", f"消息条数:{len(messages)}(开赛/进度/结算均已覆盖)", f"结算调用:{len(fake_points_service.calls)}(冠军/参赛/下注派奖)", + f"积分变化用户数:{len(saved.point_changes)}", f"过程展示:{'开启' if stream_progress else '关闭'}", ] )