Files
DanDingNoneBot/danding_bot/plugins/group_horse_racing/commands/shared.py
Mr.Xia a952760cf8 fix: break circular import in horse racing commands
Extract shared.py from commands/__init__.py to break circular dependency:
- shared.py: shared variables/services/helper functions
- access.py: get_scope/check_access/get_event_id (canonical source)
- __init__.py: re-exports from shared.py for backward compat
- register/bet/race/help: import from .shared instead of package
2026-05-02 15:38:34 +08:00

348 lines
13 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 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] = {}
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."""
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
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)
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
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}")
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
)
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")
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