- 在配置中新增图片渲染相关参数:RACE_RENDER_AS_IMAGE、RACE_IMAGE_WIDTH 等 - 复用 danding_qqpush 的 ImageRenderer,使其支持自定义标题 - 在比赛开始、结束和进度播报时,将文本消息转换为带标题的图片发送 - 修复测试用例中的消息发送函数调用
457 lines
15 KiB
Python
457 lines
15 KiB
Python
import asyncio
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
from nonebot import on_command
|
||
from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, Message, MessageSegment, PrivateMessageEvent
|
||
|
||
from danding_bot.plugins.danding_qqpush.config import Config as QqPushConfig
|
||
from danding_bot.plugins.danding_qqpush.image_render import ImageRenderer
|
||
from .room_store import RoomStore
|
||
from .points_service import PointsService
|
||
from .race_engine import RaceEngine
|
||
from .message_service import MessageService
|
||
from .models import Room, Horse, Bet, HorseState, RoomState, RaceResult
|
||
|
||
# Import config from __init__ to ensure it's loaded through NoneBot driver
|
||
from . import plugin_config as config
|
||
|
||
room_store = RoomStore(config)
|
||
points_service = PointsService(config)
|
||
race_engine = RaceEngine(config)
|
||
message_service = MessageService(config)
|
||
_race_image_renderer: ImageRenderer | None = None
|
||
|
||
|
||
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 calculate_odds(room: Room) -> dict[str, float]:
|
||
"""Calculate odds for each horse based on bet distribution."""
|
||
total_bet = sum(b.amount for b in room.bets)
|
||
odds = {}
|
||
for name in room.horses:
|
||
horse_bet = sum(b.amount for b in room.bets if b.horse_name == name)
|
||
if horse_bet == 0:
|
||
odds[name] = config.MIN_ODDS
|
||
else:
|
||
raw_odds = total_bet / horse_bet
|
||
odds[name] = max(config.MIN_ODDS, round(raw_odds, 2))
|
||
return odds
|
||
|
||
|
||
async def settle_race(room: Room):
|
||
"""Settle bets and rewards after race finishes."""
|
||
champion = room.horses.get(room.champion_name)
|
||
if not champion:
|
||
return
|
||
|
||
# Reward champion owner
|
||
await points_service.reward_champion(champion.owner_id)
|
||
|
||
# Reward all participants
|
||
for horse in room.horses.values():
|
||
if horse.owner_id != champion.owner_id:
|
||
await points_service.reward_participant(horse.owner_id)
|
||
|
||
# Settle bets
|
||
odds = calculate_odds(room)
|
||
for bet in room.bets:
|
||
if bet.horse_name == room.champion_name:
|
||
await points_service.payout_winnings(bet.user_id, bet.amount, odds.get(bet.horse_name, config.MIN_ODDS))
|
||
|
||
# Save race result
|
||
result = RaceResult(
|
||
race_id=str(uuid.uuid4()),
|
||
scope=room.scope,
|
||
champion_name=champion.name,
|
||
champion_owner=champion.owner_id,
|
||
participants=[h.name for h in room.horses.values()],
|
||
bet_distribution={name: sum(b.amount for b in room.bets if b.horse_name == name) for name in room.horses},
|
||
duration_ticks=room.tick_count,
|
||
completed_at=datetime.now(),
|
||
)
|
||
room_store.save_race_result(result)
|
||
|
||
|
||
async def _send_to_scope(bot: Bot, scope: str, message: str):
|
||
"""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 bot.send_msg(
|
||
message_type="group" if scope.startswith("group_") else "private",
|
||
group_id=int(scope.split("_", 1)[1]) if scope.startswith("group_") else None,
|
||
user_id=int(scope.split("_", 1)[1]) if scope.startswith("test_") else None,
|
||
message=outbound_message,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def run_race_with_settlement(bot: Bot, room: Room, scope: str):
|
||
"""Run race with live progress updates and settlement."""
|
||
room.state = RoomState.RUNNING
|
||
|
||
# Send start message with horse list
|
||
horse_list = "\n".join(f" {h.name}" for h in room.horses.values())
|
||
await _send_to_scope(bot, scope, f"比赛开始!\n{horse_list}")
|
||
|
||
# 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)
|
||
|
||
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
|
||
await settle_race(room)
|
||
|
||
# Build result message
|
||
odds = calculate_odds(room)
|
||
champion = room.horses.get(room.champion_name)
|
||
result_lines = [
|
||
f"比赛结束!冠军:{room.champion_name}",
|
||
f"马主 {champion.owner_id if champion else '?'} 获得 {config.CHAMPION_REWARD} 积分",
|
||
]
|
||
winning_bets = [b for b in room.bets if b.horse_name == room.champion_name]
|
||
if winning_bets:
|
||
result_lines.append("下注中奖:")
|
||
for b in winning_bets:
|
||
payout = int(b.amount * odds.get(b.horse_name, config.MIN_ODDS))
|
||
result_lines.append(f" {b.user_id} 下注 {b.amount} -> 获得 {payout}")
|
||
|
||
await _send_to_scope(bot, scope, "\n".join(result_lines))
|
||
|
||
# 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
|
||
|
||
# Parse horse name from message
|
||
msg = str(event.get_message()).strip()
|
||
parts = msg.split(None, 1)
|
||
horse_name = parts[1].strip() if len(parts) > 1 else ""
|
||
|
||
if not horse_name:
|
||
await register_cmd.finish("请输入马匹名:/赛马报名 <马匹名>")
|
||
return
|
||
|
||
if len(horse_name) > 10:
|
||
await register_cmd.finish("马匹名不能超过10个字符")
|
||
return
|
||
|
||
scope = get_scope(event)
|
||
user_id = get_event_id(event)
|
||
lock = room_store.get_lock(scope)
|
||
|
||
async with lock:
|
||
room = room_store.get_room(scope)
|
||
if not room:
|
||
room = room_store.create_room(scope)
|
||
|
||
if room.state != RoomState.WAITING:
|
||
await register_cmd.finish("比赛正在进行中,无法报名")
|
||
return
|
||
|
||
if len(room.horses) >= 8:
|
||
await register_cmd.finish("房间已满(最多8匹马)")
|
||
return
|
||
|
||
if horse_name in room.horses:
|
||
await register_cmd.finish(f"马匹名 \"{horse_name}\" 已被使用")
|
||
return
|
||
|
||
# Check if user already registered
|
||
for h in room.horses.values():
|
||
if h.owner_id == user_id:
|
||
await register_cmd.finish("你已经报名了,不能重复报名")
|
||
return
|
||
|
||
# Create horse
|
||
room.horses[horse_name] = Horse(owner_id=user_id, name=horse_name)
|
||
|
||
count = len(room.horses)
|
||
await register_cmd.finish(f"报名成功!马匹 \"{horse_name}\" 已加入比赛({count}/8)")
|
||
|
||
|
||
cancel_cmd = on_command("赛马取消报名", priority=5)
|
||
|
||
|
||
@cancel_cmd.handle()
|
||
async def handle_cancel(bot: Bot, event: Event):
|
||
"""Handle cancel registration."""
|
||
if not await check_access(bot, event):
|
||
await cancel_cmd.finish("无权限访问此功能")
|
||
return
|
||
|
||
scope = get_scope(event)
|
||
user_id = get_event_id(event)
|
||
lock = room_store.get_lock(scope)
|
||
|
||
async with lock:
|
||
room = room_store.get_room(scope)
|
||
if not room:
|
||
await cancel_cmd.finish("房间不存在")
|
||
return
|
||
|
||
if room.state != RoomState.WAITING:
|
||
await cancel_cmd.finish("比赛正在进行中,无法取消报名")
|
||
return
|
||
|
||
# Find user's horse
|
||
user_horse = None
|
||
for name, horse in room.horses.items():
|
||
if horse.owner_id == user_id:
|
||
user_horse = name
|
||
break
|
||
|
||
if not user_horse:
|
||
await cancel_cmd.finish("你还没有报名")
|
||
return
|
||
|
||
# Refund bets on this horse
|
||
bets_to_refund = [b for b in room.bets if b.horse_name == user_horse]
|
||
for bet in bets_to_refund:
|
||
await points_service.refund_bet_points(bet.user_id, bet.amount, "取消报名退还下注")
|
||
room.bets = [b for b in room.bets if b.horse_name != user_horse]
|
||
|
||
# Remove horse
|
||
del room.horses[user_horse]
|
||
|
||
await cancel_cmd.finish(f"已取消报名,马匹 \"{user_horse}\" 已退出")
|
||
|
||
|
||
bet_cmd = on_command("赛马下注", priority=5)
|
||
|
||
|
||
@bet_cmd.handle()
|
||
async def handle_bet(bot: Bot, event: Event):
|
||
"""Handle bet placement."""
|
||
if not await check_access(bot, event):
|
||
await bet_cmd.finish("无权限访问此功能")
|
||
return
|
||
|
||
# Parse arguments: /赛马下注 <马匹名> <金额>
|
||
msg = str(event.get_message()).strip()
|
||
parts = msg.split()
|
||
if len(parts) < 3:
|
||
await bet_cmd.finish("请使用:/赛马下注 <马匹名> <金额>")
|
||
return
|
||
|
||
horse_name = parts[1]
|
||
try:
|
||
amount = int(parts[2])
|
||
except ValueError:
|
||
await bet_cmd.finish("金额必须是正整数")
|
||
return
|
||
|
||
if amount < config.MIN_BET:
|
||
await bet_cmd.finish(f"最低下注金额为 {config.MIN_BET}")
|
||
return
|
||
|
||
scope = get_scope(event)
|
||
user_id = get_event_id(event)
|
||
lock = room_store.get_lock(scope)
|
||
|
||
async with lock:
|
||
room = room_store.get_room(scope)
|
||
if not room:
|
||
await bet_cmd.finish("房间不存在,请先报名")
|
||
return
|
||
|
||
if room.state != RoomState.WAITING:
|
||
await bet_cmd.finish("比赛正在进行中,无法下注")
|
||
return
|
||
|
||
if horse_name not in room.horses:
|
||
await bet_cmd.finish(f"马匹 \"{horse_name}\" 不存在")
|
||
return
|
||
|
||
# Can't bet on your own horse
|
||
if room.horses[horse_name].owner_id == user_id:
|
||
await bet_cmd.finish("不能给自己的马下注")
|
||
return
|
||
|
||
# Deduct points first
|
||
success, balance = await points_service.spend_bet_points(user_id, amount, f"下注 {horse_name}")
|
||
if not success:
|
||
await bet_cmd.finish(f"积分不足(当前余额:{balance})")
|
||
return
|
||
|
||
# Record bet
|
||
room.bets.append(Bet(user_id=user_id, horse_name=horse_name, amount=amount))
|
||
|
||
odds = calculate_odds(room)
|
||
await bet_cmd.finish(f"下注成功!{horse_name} {amount}积分(当前赔率:{odds.get(horse_name, config.MIN_ODDS):.2f})")
|
||
|
||
|
||
odds_cmd = on_command("赛马赔率", priority=5)
|
||
|
||
|
||
@odds_cmd.handle()
|
||
async def handle_odds(bot: Bot, event: Event):
|
||
"""Handle odds display."""
|
||
if not await check_access(bot, event):
|
||
await odds_cmd.finish("无权限访问此功能")
|
||
return
|
||
|
||
scope = get_scope(event)
|
||
room = room_store.get_room(scope)
|
||
if not room:
|
||
await odds_cmd.finish("房间不存在,请先报名")
|
||
return
|
||
|
||
if not room.horses:
|
||
await odds_cmd.finish("还没有马匹报名")
|
||
return
|
||
|
||
odds = calculate_odds(room)
|
||
lines = ["当前赔率:"]
|
||
total_bet = sum(b.amount for b in room.bets)
|
||
for name, odd in odds.items():
|
||
horse_bet = sum(b.amount for b in room.bets if b.horse_name == name)
|
||
lines.append(f" {name} - {odd:.2f}倍 (总下注: {horse_bet})")
|
||
lines.append(f"总下注池: {total_bet}")
|
||
|
||
await odds_cmd.finish("\n".join(lines))
|
||
|
||
|
||
start_cmd = on_command("赛马开赛", priority=5)
|
||
|
||
|
||
@start_cmd.handle()
|
||
async def handle_start(bot: Bot, event: Event):
|
||
"""Handle race start."""
|
||
if not await check_access(bot, event):
|
||
await start_cmd.finish("无权限访问此功能")
|
||
return
|
||
|
||
scope = get_scope(event)
|
||
lock = room_store.get_lock(scope)
|
||
|
||
async with lock:
|
||
room = room_store.get_room(scope)
|
||
if not room:
|
||
await start_cmd.finish("房间不存在,请先报名")
|
||
return
|
||
|
||
if room.state != RoomState.WAITING:
|
||
await start_cmd.finish("比赛已经在进行中")
|
||
return
|
||
|
||
if len(room.horses) < 2:
|
||
await start_cmd.finish("至少需要2匹马才能开赛")
|
||
return
|
||
|
||
# Set all horses to racing state
|
||
for horse in room.horses.values():
|
||
horse.state = HorseState.RACING
|
||
|
||
await start_cmd.finish("比赛开始!")
|
||
|
||
# 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 = """赛马命令帮助:
|
||
/赛马报名 <马匹名> - 报名参赛
|
||
/赛马取消报名 - 取消报名并退还下注
|
||
/赛马下注 <马匹名> <金额> - 下注(不能给自己的马下注)
|
||
/赛马赔率 - 查看当前赔率
|
||
/赛马开赛 - 开始比赛(至少2匹马)
|
||
/赛马帮助 - 显示此帮助"""
|
||
await help_cmd.finish(help_text)
|