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
This commit is contained in:
347
danding_bot/plugins/group_horse_racing/commands/shared.py
Normal file
347
danding_bot/plugins/group_horse_racing/commands/shared.py
Normal file
@@ -0,0 +1,347 @@
|
||||
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
|
||||
Reference in New Issue
Block a user