Files
DanDingNoneBot/danding_bot/plugins/group_horse_racing/commands/shared.py

384 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
import asyncio
from datetime import datetime
from uuid import uuid4
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 room_store # use the singleton managed by __init__.py lifecycle hooks
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")
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 as exc:
logger.debug("获取赛马用户昵称失败 scope=%s user_id=%s error=%s", scope, user_id, exc)
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 "略有损失"
async def _is_admin_or_owner(bot: Bot, event: Event) -> bool:
"""Check if the event sender is a group admin or owner."""
if not isinstance(event, GroupMessageEvent):
return False
try:
member_info = await bot.get_group_member_info(
group_id=event.group_id,
user_id=int(event.get_user_id()),
)
return member_info.get("role", "") in ("admin", "owner")
except Exception as exc:
logger.debug("检查赛马管理员权限失败 user_id=%s error=%s", getattr(event, "user_id", ""), exc)
return False
def _build_point_changes(room: Room, odds: dict[str, float]) -> tuple[dict[str, int], dict[str, str]]:
point_changes: dict[str, int] = {}
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
def _build_participants_snapshot(room: Room) -> list[str]:
"""生成赛果归档所需的参赛马名快照。"""
return [horse.name for horse in _get_horses_in_order(room)]
def _build_bet_distribution(room: Room) -> dict[str, int]:
"""按马名汇总下注分布,供 xapi 原样归档。"""
distribution = {horse.name: 0 for horse in _get_horses_in_order(room)}
for bet in room.bets:
distribution[bet.horse_name] = distribution.get(bet.horse_name, 0) + bet.amount
return distribution
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."""
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:
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
odds = calculate_odds(room)
# Collect all affected user IDs
user_ids: set[str] = set()
for horse in room.horses.values():
user_ids.add(horse.owner_id)
for bet in room.bets:
user_ids.add(bet.user_id)
# Record pre-balances
pre_balances: dict[str, int] = {}
for uid in user_ids:
pre_balances[uid] = await points_service.get_balance(uid)
# 1. Reward all participants
for horse in room.horses.values():
try:
await points_service.reward_participant(horse.owner_id)
except Exception as e:
logger.warning(f"reward_participant failed for {horse.owner_id}: {e}")
# 2. Champion bonus
try:
await points_service.reward_champion(champion.owner_id)
except Exception as e:
logger.warning(f"reward_champion failed for {champion.owner_id}: {e}")
# 3. Bet payouts for winners
for bet in room.bets:
if bet.horse_name == room.champion_name:
try:
await points_service.payout_winnings(
bet.user_id, bet.amount, odds.get(bet.horse_name, config.MIN_ODDS)
)
except Exception as e:
logger.warning(f"payout_winnings failed for {bet.user_id}: {e}")
# Record post-balances and compute deltas
post_balances: dict[str, int] = {}
for uid in user_ids:
post_balances[uid] = await points_service.get_balance(uid)
point_changes: dict[str, int] = {}
for uid in user_ids:
delta = post_balances[uid] - pre_balances[uid]
if delta != 0:
point_changes[uid] = delta
# Build human-readable summaries
_, point_change_summaries = _build_point_changes(room, odds)
result = RaceResult(
race_id=str(uuid4()),
scope=room.scope,
champion_name=room.champion_name,
champion_owner=champion.owner_id,
participants=_build_participants_snapshot(room),
bet_distribution=_build_bet_distribution(room),
duration_ticks=room.tick_count,
completed_at=datetime.now(),
point_changes=point_changes,
point_change_summaries=point_change_summaries,
odds_snapshot=odds,
)
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")
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
settlement = await settle_race(room)
result = settlement[0] if settlement else None
odds = settlement[1] if settlement else {}
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))
await message_service.recall_previous_of_type(bot, scope, "race_update")
await _send_to_scope(bot, scope, "\n".join(result_lines), "race_result")
if result:
await room_store.save_race_result(result)
race_engine.stop_race(scope)
room_store.delete_room(scope)
message_service.clear_pending_recalls(scope)
# Import and re-export access functions from access.py (canonical source)
from .access import get_event_id, get_scope, check_access