fix(race): 代码质量审查修复 + commands包拆分 + 赛马取消命令

- 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
This commit is contained in:
2026-05-02 14:33:34 +08:00
parent 5869618a9c
commit fe081f43cf
11 changed files with 1229 additions and 983 deletions

View File

@@ -1,30 +1,43 @@
from nonebot import require, get_driver from nonebot import require, get_driver
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from .config import Config from .config import Config
require("danding_bot.plugins.danding_points") require("danding_bot.plugins.danding_points")
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="Group Horse Racing", name="Group Horse Racing",
description="Group horse racing plugin with betting and points integration", description="Group horse racing plugin with betting and points integration",
usage="Use /赛马 commands for horse racing gameplay", usage="Use /赛马 commands for horse racing gameplay",
type="application", type="application",
config=Config, config=Config,
extra={ extra={
"required_plugins": ["danding_bot.plugins.danding_points"], "required_plugins": ["danding_bot.plugins.danding_points"],
}, },
) )
# 从 NoneBot driver config 中提取本插件配置(去掉 GROUP_HORSE_RACING_ 前缀) # 从 NoneBot driver config 中提取本插件配置(去掉 GROUP_HORSE_RACING_ 前缀)
_nb_config = get_driver().config _nb_config = get_driver().config
_raw = _nb_config.model_dump() if hasattr(_nb_config, "model_dump") else _nb_config.dict() _raw = _nb_config.model_dump() if hasattr(_nb_config, "model_dump") else _nb_config.dict()
_prefix = "group_horse_racing_" _prefix = "group_horse_racing_"
plugin_config = Config(**{ plugin_config = Config(**{
k[len(_prefix):].upper(): v k[len(_prefix):].upper(): v
for k, v in _raw.items() for k, v in _raw.items()
if k.startswith(_prefix) if k.startswith(_prefix)
}) })
from . import commands, test_commands # noqa: F401, E402 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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -1,74 +1,76 @@
from pydantic import Field, field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
import json import json
class Config(BaseSettings): class Config(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
extra="ignore", extra="ignore",
env_prefix="GROUP_HORSE_RACING_", env_prefix="GROUP_HORSE_RACING_",
) )
# 测试模式配置 # 测试模式配置
TEST_MODE: bool = False TEST_MODE: bool = False
TESTERS: set[int] = Field(default_factory=set) TESTERS: set[int] = Field(default_factory=set)
TEST_GROUPS: set[int] = Field(default_factory=set) TEST_GROUPS: set[int] = Field(default_factory=set)
ALLOWED_GROUPS: set[int] = Field(default_factory=set) ALLOWED_GROUPS: set[int] = Field(default_factory=set)
# 奖励配置 # 奖励配置
PARTICIPANT_REWARD: int = 20 PARTICIPANT_REWARD: int = 20
CHAMPION_REWARD: int = 150 CHAMPION_REWARD: int = 150
MIN_BET: int = 10 MIN_BET: int = 10
MIN_ODDS: float = 1.2 MIN_ODDS: float = 1.2
RACE_DISTANCE: int = 100 RACE_DISTANCE: int = 100
RACE_TICK_INTERVAL: int = 5 RACE_TICK_INTERVAL: int = 5
RACE_RENDER_AS_IMAGE: bool = True RACE_RENDER_AS_IMAGE: bool = True
RACE_IMAGE_WIDTH: int = 900 RACE_IMAGE_WIDTH: int = 900
RACE_IMAGE_FONT_SIZE: int = 26 RACE_IMAGE_FONT_SIZE: int = 26
RACE_IMAGE_PADDING: int = 28 RACE_IMAGE_PADDING: int = 28
RACE_IMAGE_LINE_SPACING: float = 1.35 RACE_IMAGE_LINE_SPACING: float = 1.35
# 消息撤回配置 # 消息撤回配置
MESSAGE_RECALL: dict[str, int] = Field( MESSAGE_RECALL: dict[str, int] = Field(
default_factory=lambda: { default_factory=lambda: {
"race_update": 30, "race_update": 30,
"registration": 180, "registration": 180,
"bet_confirm": 180, "bet_confirm": 180,
"cancel_confirm": 60, "cancel_confirm": 60,
"error": 60, "error": 60,
"race_result": 0, "race_result": 0,
"leaderboard": 0, "leaderboard": 0,
"help": 0, "help": 0,
"odds_display": 0, "odds_display": 0,
} }
) )
# 数据库配置 # 数据库配置
RACE_DB_FILE: str = "data/group_horse_racing/race.db" RACE_DB_FILE: str = "data/group_horse_racing/race.db"
@field_validator("TESTERS", "TEST_GROUPS", "ALLOWED_GROUPS", mode="before") @field_validator("TESTERS", "TEST_GROUPS", "ALLOWED_GROUPS", mode="before")
@classmethod @classmethod
def parse_id_sets(cls, v): def parse_id_sets(cls, v):
"""Parse ID sets from various formats.""" """Parse ID sets from various formats."""
if isinstance(v, set): if isinstance(v, set):
return v return v
if isinstance(v, str): if isinstance(v, str):
return cls._parse_id_set(v) return cls._parse_id_set(v)
if isinstance(v, (list, tuple)): if isinstance(v, (list, tuple)):
return set(int(x) for x in v) return set(int(x) for x in v)
return v if isinstance(v, set) else set() return v if isinstance(v, set) else set()
@staticmethod @staticmethod
def _parse_id_set(v: str) -> set[int]: def _parse_id_set(v: str) -> set[int]:
"""Parse ID sets from various formats.""" """Parse ID sets from various formats."""
try: try:
parsed = json.loads(v) parsed = json.loads(v)
if isinstance(parsed, list): if isinstance(parsed, list):
return set(int(x) for x in parsed) return set(int(x) for x in parsed)
except (json.JSONDecodeError, ValueError, TypeError): except (json.JSONDecodeError, ValueError, TypeError) as e:
pass import logging
try: logging.getLogger(__name__).warning(f"TESTERS 解析失败: {v}, error: {e}")
return set(int(x.strip()) for x in v.split(",") if x.strip()) pass
except ValueError: try:
pass return set(int(x.strip()) for x in v.split(",") if x.strip())
return set() except ValueError:
pass
return set()

View File

@@ -1,80 +1,85 @@
import asyncio import asyncio
import random import random
from typing import Optional from typing import Optional
from .models import Room, RoomState, Horse, HorseState from .models import Room, RoomState, Horse, HorseState
from .config import Config from .config import Config
class RaceEngine: class RaceEngine:
def __init__(self, config: Config): def __init__(self, config: Config):
self.config = config self.config = config
self.active_tasks: dict[str, asyncio.Task] = {} self.active_tasks: dict[str, asyncio.Task] = {}
def tick(self, room: Room): def tick(self, room: Room):
"""Advance race by one tick. Returns list of finished horses.""" """Advance race by one tick. Returns list of finished horses."""
room.tick_count += 1 room.tick_count += 1
for horse in room.horses.values(): for horse in room.horses.values():
if horse.state == HorseState.RACING: if horse.state == HorseState.RACING:
distance = max(0, random.gauss(10, 3)) distance = max(0.5, random.gauss(10, 3)) # Minimum 0.5m displacement
horse.position += distance horse.position += distance
finished_horses = [ finished_horses = [
h for h in room.horses.values() h for h in room.horses.values()
if h.position >= self.config.RACE_DISTANCE if h.position >= self.config.RACE_DISTANCE
] ]
return finished_horses return finished_horses
def determine_champion(self, horses: list[Horse]) -> Horse: MAX_ROUNDS = 100 # Safety valve
"""Determine champion from tied horses."""
if len(horses) == 1: def determine_champion(self, horses: list[Horse]) -> Horse:
return horses[0] """Determine champion from tied horses."""
if len(horses) == 1:
while len(horses) > 1: return horses[0]
distances = [max(0, random.gauss(10, 3)) for _ in horses]
max_distance = max(distances) for _ in range(self.MAX_ROUNDS):
horses = [h for h, d in zip(horses, distances) if d == max_distance] distances = [max(0, random.gauss(10, 3)) for _ in horses]
max_distance = max(distances)
return horses[0] horses = [h for h, d in zip(horses, distances) if d == max_distance]
if len(horses) == 1:
def register_task(self, scope: str, task: asyncio.Task): return horses[0]
"""Register an active race task."""
self.active_tasks[scope] = task # Fallback: random choice after max rounds
return random.choice(horses)
def stop_race(self, scope: str):
"""Stop race and cancel task.""" def register_task(self, scope: str, task: asyncio.Task):
if scope in self.active_tasks: """Register an active race task."""
task = self.active_tasks[scope] self.active_tasks[scope] = task
if not task.done():
task.cancel() def stop_race(self, scope: str):
del self.active_tasks[scope] """Stop race and cancel task."""
if scope in self.active_tasks:
def format_progress(self, room: Room) -> str: task = self.active_tasks[scope]
"""Format race progress as visual bar for each horse.""" if not task.done():
distance = self.config.RACE_DISTANCE task.cancel()
bar_width = 20 del self.active_tasks[scope]
lines = [f"【第{room.tick_count}回合】"]
def format_progress(self, room: Room) -> str:
sorted_horses = sorted(room.horses.values(), key=lambda h: h.index) """Format race progress as visual bar for each horse."""
# Calculate max display width for name alignment distance = self.config.RACE_DISTANCE
# Each CJK/fullwidth char counts as 2, others as 1 bar_width = 20
def _display_width(s: str) -> int: lines = [f"【第{room.tick_count}回合】"]
w = 0
for ch in s: sorted_horses = sorted(room.horses.values(), key=lambda h: h.index)
w += 2 if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or '\uff00' <= ch <= '\uffef' else 1 # Calculate max display width for name alignment
return w # Each CJK/fullwidth char counts as 2, others as 1
def _display_width(s: str) -> int:
max_name_width = max(_display_width(h.name) for h in sorted_horses) w = 0
for ch in s:
for horse in sorted_horses: w += 2 if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or '\uff00' <= ch <= '\uffef' else 1
progress = min(horse.position / distance, 1.0) return w
filled = int(progress * bar_width)
bar = "" * filled + "" * (bar_width - filled) max_name_width = max(_display_width(h.name) for h in sorted_horses)
# Pad with fullwidth spaces (1 fullwidth space = 2 columns = 1 CJK char width)
diff = max_name_width - _display_width(horse.name) for horse in sorted_horses:
pad = "\u3000" * (diff // 2) + (" " if diff % 2 else "") progress = min(horse.position / distance, 1.0)
lines.append(f" {horse.index:02d}{horse.name}{pad} |{bar}| {horse.position:.1f}m") filled = int(progress * bar_width)
bar = "" * filled + "" * (bar_width - filled)
return "\n".join(lines) # 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)

View File

@@ -17,66 +17,67 @@ class RoomStore:
self.db_path = Path(config.RACE_DB_FILE) self.db_path = Path(config.RACE_DB_FILE)
self.db_path.parent.mkdir(parents=True, exist_ok=True) self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._initialized = False self._initialized = False
self._db: Optional[aiosqlite.Connection] = None
async def _init_db(self): async def _init_db(self):
"""Initialize database tables asynchronously.""" """Initialize database tables asynchronously."""
if self._initialized: if self._initialized:
return 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(""" db = await self._get_db()
CREATE TABLE IF NOT EXISTS race_history ( await db.execute("""
race_id TEXT PRIMARY KEY, CREATE TABLE IF NOT EXISTS room_snapshots (
scope TEXT NOT NULL, scope TEXT PRIMARY KEY,
champion_name TEXT NOT NULL, state TEXT NOT NULL,
champion_owner TEXT NOT NULL, created_at TEXT NOT NULL,
participants TEXT NOT NULL, horses TEXT NOT NULL,
bet_distribution TEXT NOT NULL, bets TEXT NOT NULL,
duration_ticks INTEGER NOT NULL, champion_name TEXT,
completed_at TEXT NOT NULL, tick_count INTEGER DEFAULT 0
point_changes TEXT DEFAULT '{}', )
point_change_summaries TEXT DEFAULT '{}', """)
odds_snapshot TEXT DEFAULT '{}'
)
""")
await db.execute(""" await db.execute("""
CREATE TABLE IF NOT EXISTS user_horse_names ( CREATE TABLE IF NOT EXISTS race_history (
user_id TEXT PRIMARY KEY, race_id TEXT PRIMARY KEY,
horse_name TEXT NOT NULL 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:
try: await db.execute("SELECT point_change_summaries FROM race_history LIMIT 1")
await db.execute("SELECT point_changes FROM race_history LIMIT 1") except aiosqlite.OperationalError:
except aiosqlite.OperationalError: await db.execute("ALTER TABLE race_history ADD COLUMN point_change_summaries TEXT DEFAULT '{}'")
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 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 self._initialized = True
async def ensure_initialized(self): async def ensure_initialized(self):
@@ -84,6 +85,62 @@ class RoomStore:
if not self._initialized: if not self._initialized:
await self._init_db() 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: def get_lock(self, scope: str) -> asyncio.Lock:
"""Get or create per-room lock.""" """Get or create per-room lock."""
if scope not in self._locks: if scope not in self._locks:
@@ -108,22 +165,22 @@ class RoomStore:
async def get_last_horse_name(self, user_id: str) -> Optional[str]: async def get_last_horse_name(self, user_id: str) -> Optional[str]:
await self.ensure_initialized() await self.ensure_initialized()
async with aiosqlite.connect(self.db_path) as db: db = await self._get_db()
cursor = await db.execute( cursor = await db.execute(
"SELECT horse_name FROM user_horse_names WHERE user_id = ?", "SELECT horse_name FROM user_horse_names WHERE user_id = ?",
(user_id,) (user_id,)
) )
row = await cursor.fetchone() row = await cursor.fetchone()
return row[0] if row else None return row[0] if row else None
async def set_last_horse_name(self, user_id: str, horse_name: str): async def set_last_horse_name(self, user_id: str, horse_name: str):
await self.ensure_initialized() await self.ensure_initialized()
async with aiosqlite.connect(self.db_path) as db: db = await self._get_db()
await db.execute( await db.execute(
"INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)", "INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)",
(user_id, horse_name), (user_id, horse_name),
) )
await db.commit() await db.commit()
async def _save_snapshot(self, room: Room): async def _save_snapshot(self, room: Room):
"""Save room snapshot to database.""" """Save room snapshot to database."""
@@ -149,44 +206,44 @@ class RoomStore:
for bet in room.bets for bet in room.bets
]) ])
async with aiosqlite.connect(self.db_path) as db: db = await self._get_db()
await db.execute(""" await db.execute("""
INSERT OR REPLACE INTO room_snapshots INSERT OR REPLACE INTO room_snapshots
(scope, state, created_at, horses, bets, champion_name, tick_count) (scope, state, created_at, horses, bets, champion_name, tick_count)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", ( """, (
room.scope, room.scope,
room.state.value, room.state.value,
room.created_at.isoformat(), room.created_at.isoformat(),
horses_json, horses_json,
bets_json, bets_json,
room.champion_name, room.champion_name,
room.tick_count, room.tick_count,
)) ))
await db.commit() await db.commit()
async def save_race_result(self, result: RaceResult): async def save_race_result(self, result: RaceResult):
"""Save race result to history.""" """Save race result to history."""
await self.ensure_initialized() await self.ensure_initialized()
async with aiosqlite.connect(self.db_path) as db: db = await self._get_db()
await db.execute(""" await db.execute("""
INSERT INTO race_history INSERT INTO race_history
(race_id, scope, champion_name, champion_owner, participants, (race_id, scope, champion_name, champion_owner, participants,
bet_distribution, duration_ticks, completed_at, bet_distribution, duration_ticks, completed_at,
point_changes, point_change_summaries, odds_snapshot) point_changes, point_change_summaries, odds_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
result.race_id, result.race_id,
result.scope, result.scope,
result.champion_name, result.champion_name,
result.champion_owner, result.champion_owner,
json.dumps(result.participants), json.dumps(result.participants),
json.dumps(result.bet_distribution), json.dumps(result.bet_distribution),
result.duration_ticks, result.duration_ticks,
result.completed_at.isoformat(), result.completed_at.isoformat(),
json.dumps(getattr(result, 'point_changes', {})), json.dumps(getattr(result, 'point_changes', {})),
json.dumps(getattr(result, 'point_change_summaries', {})), json.dumps(getattr(result, 'point_change_summaries', {})),
json.dumps(getattr(result, 'odds_snapshot', {})), json.dumps(getattr(result, 'odds_snapshot', {})),
)) ))
await db.commit() await db.commit()