fix: 赛马插件P0-P2问题修复

- P0: room_store sqlite3→aiosqlite异步化
- P0: points_service统一异常处理+轻量重试
- P0: _send_to_scope加warning日志
- P1: 积分历史记录补充source/reason字段
- P1: 赛马结算写入赔率快照(odds_snapshot)
- P1: test_commands改为commands_mod间接引用(测试隔离)
- P2: 马名去重统一casefold()比较
This commit is contained in:
2026-05-01 22:50:14 +08:00
parent dd8781a74d
commit 569801dd14
4 changed files with 1298 additions and 1242 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +1,86 @@
from typing import Tuple import asyncio
from danding_bot.plugins.danding_points import points_api import logging
from .config import Config from typing import Tuple
from danding_bot.plugins.danding_points import points_api
from .config import Config
class PointsService:
def __init__(self, config: Config): logger = logging.getLogger("horse_racing.points")
self.config = config
MAX_RETRIES = 3
async def spend_bet_points( RETRY_DELAY = 0.5
self, user_id: str, amount: int, reason: str = "赛马下注"
) -> Tuple[bool, int]:
"""Deduct points for betting with retry.""" class PointsService:
success, balance = await points_api.spend_points( def __init__(self, config: Config):
user_id, amount, "horse_race", reason self.config = config
)
if not success: async def _call_with_retry(self, func, *args, retries=MAX_RETRIES):
success, balance = await points_api.spend_points( """Call API function with retry logic."""
user_id, amount, "horse_race", reason last_exc = None
) for attempt in range(retries):
return success, balance try:
return await func(*args)
async def refund_bet_points( except Exception as e:
self, user_id: str, amount: int, reason: str = "取消报名退还" last_exc = e
) -> Tuple[bool, int]: logger.warning(
"""Refund bet points.""" f"Points API call failed (attempt {attempt + 1}/{retries}): {e}"
return await points_api.add_points(user_id, amount, "horse_race", reason) )
if attempt < retries - 1:
async def payout_winnings( await asyncio.sleep(RETRY_DELAY)
self, user_id: str, amount: int, odds: float logger.error(f"Points API call failed after {retries} attempts: {last_exc}")
) -> Tuple[bool, int]: raise last_exc
"""Payout bet winnings."""
payout = int(amount * odds) async def spend_bet_points(
reason = f"下注获胜 ×{odds:.2f}" self, user_id: str, amount: int, reason: str = "赛马下注"
return await points_api.add_points(user_id, payout, "horse_race", reason) ) -> Tuple[bool, int]:
"""Deduct points for betting with retry."""
async def reward_participant(self, user_id: str) -> Tuple[bool, int]: try:
"""Reward race participant.""" return await self._call_with_retry(
return await points_api.add_points( points_api.spend_points,
user_id, user_id, amount, "horse_race", reason
self.config.PARTICIPANT_REWARD, )
"horse_race", except Exception as e:
"参赛奖励", logger.error(f"spend_bet_points failed for user {user_id}: {e}")
) return False, 0
async def reward_champion(self, user_id: str) -> Tuple[bool, int]: async def refund_bet_points(
"""Reward race champion.""" self, user_id: str, amount: int, reason: str = "取消报名退还"
return await points_api.add_points( ) -> Tuple[bool, int]:
user_id, """Refund bet points."""
self.config.CHAMPION_REWARD, return await points_api.add_points(user_id, amount, "horse_race", reason)
"horse_race",
"冠军奖励", async def payout_winnings(
) self, user_id: str, amount: int, odds: float
) -> Tuple[bool, int]:
async def set_points( """Payout bet winnings."""
self, user_id: str, amount: int, reason: str = "测试设置积分" payout = int(amount * odds)
) -> Tuple[bool, int]: reason = f"下注获胜 ×{odds:.2f}"
"""Set user points (for testing).""" return await points_api.add_points(user_id, payout, "horse_race", reason)
return await points_api.set_points(user_id, amount, "horse_race", reason)
async def reward_participant(self, user_id: str) -> Tuple[bool, int]:
async def get_balance(self, user_id: str) -> int: """Reward race participant."""
"""Get user balance.""" return await points_api.add_points(
return await points_api.get_balance(user_id) user_id,
self.config.PARTICIPANT_REWARD,
"horse_race",
"参赛奖励",
)
async def reward_champion(self, user_id: str) -> Tuple[bool, int]:
"""Reward race champion."""
return await points_api.add_points(
user_id,
self.config.CHAMPION_REWARD,
"horse_race",
"冠军奖励",
)
async def set_points(
self, user_id: str, amount: int, reason: str = "测试设置积分"
) -> Tuple[bool, int]:
"""Set user points (for testing)."""
return await points_api.set_points(user_id, amount, "horse_race", reason)
async def get_balance(self, user_id: str) -> int:
"""Get user balance."""
return await points_api.get_balance(user_id)

View File

@@ -1,163 +1,192 @@
import asyncio import asyncio
import sqlite3 import aiosqlite
from datetime import datetime import json
from pathlib import Path from datetime import datetime
from typing import Optional from pathlib import Path
from typing import Optional
from .models import Room, RoomState, RaceResult
from .config import Config from .models import Room, RoomState, RaceResult
from .config import Config
class RoomStore:
def __init__(self, config: Config): class RoomStore:
self.config = config def __init__(self, config: Config):
self.rooms: dict[str, Room] = {} self.config = config
self._locks: dict[str, asyncio.Lock] = {} self.rooms: dict[str, Room] = {}
self.db_path = Path(config.RACE_DB_FILE) self._locks: dict[str, asyncio.Lock] = {}
self.db_path.parent.mkdir(parents=True, exist_ok=True) self.db_path = Path(config.RACE_DB_FILE)
self._init_db() self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._initialized = False
def _init_db(self):
"""Initialize database tables.""" async def _init_db(self):
conn = sqlite3.connect(self.db_path) """Initialize database tables asynchronously."""
cursor = conn.cursor() if self._initialized:
return
cursor.execute("""
CREATE TABLE IF NOT EXISTS room_snapshots ( async with aiosqlite.connect(self.db_path) as db:
scope TEXT PRIMARY KEY, await db.execute("""
state TEXT NOT NULL, CREATE TABLE IF NOT EXISTS room_snapshots (
created_at TEXT NOT NULL, scope TEXT PRIMARY KEY,
horses TEXT NOT NULL, state TEXT NOT NULL,
bets TEXT NOT NULL, created_at TEXT NOT NULL,
champion_name TEXT, horses TEXT NOT NULL,
tick_count INTEGER DEFAULT 0 bets TEXT NOT NULL,
) champion_name TEXT,
""") tick_count INTEGER DEFAULT 0
)
cursor.execute(""" """)
CREATE TABLE IF NOT EXISTS race_history (
race_id TEXT PRIMARY KEY, await db.execute("""
scope TEXT NOT NULL, CREATE TABLE IF NOT EXISTS race_history (
champion_name TEXT NOT NULL, race_id TEXT PRIMARY KEY,
champion_owner TEXT NOT NULL, scope TEXT NOT NULL,
participants TEXT NOT NULL, champion_name TEXT NOT NULL,
bet_distribution TEXT NOT NULL, champion_owner TEXT NOT NULL,
duration_ticks INTEGER NOT NULL, participants TEXT NOT NULL,
completed_at TEXT NOT NULL bet_distribution TEXT NOT NULL,
) duration_ticks INTEGER NOT NULL,
""") completed_at TEXT NOT NULL,
point_changes TEXT DEFAULT '{}',
cursor.execute(""" point_change_summaries TEXT DEFAULT '{}',
CREATE TABLE IF NOT EXISTS user_horse_names ( odds_snapshot TEXT DEFAULT '{}'
user_id TEXT PRIMARY KEY, )
horse_name TEXT NOT NULL """)
)
""") await db.execute("""
CREATE TABLE IF NOT EXISTS user_horse_names (
conn.commit() user_id TEXT PRIMARY KEY,
conn.close() horse_name TEXT NOT NULL
)
def get_lock(self, scope: str) -> asyncio.Lock: """)
"""Get or create per-room lock."""
if scope not in self._locks: # Add missing columns if they don't exist (for existing databases)
self._locks[scope] = asyncio.Lock() try:
return self._locks[scope] await db.execute("SELECT point_changes FROM race_history LIMIT 1")
except aiosqlite.OperationalError:
def get_room(self, scope: str) -> Optional[Room]: await db.execute("ALTER TABLE race_history ADD COLUMN point_changes TEXT DEFAULT '{}'")
"""Get room by scope."""
return self.rooms.get(scope) try:
await db.execute("SELECT point_change_summaries FROM race_history LIMIT 1")
def create_room(self, scope: str) -> Room: except aiosqlite.OperationalError:
"""Create new room.""" await db.execute("ALTER TABLE race_history ADD COLUMN point_change_summaries TEXT DEFAULT '{}'")
room = Room(scope=scope)
self.rooms[scope] = room try:
self._save_snapshot(room) await db.execute("SELECT odds_snapshot FROM race_history LIMIT 1")
return room except aiosqlite.OperationalError:
await db.execute("ALTER TABLE race_history ADD COLUMN odds_snapshot TEXT DEFAULT '{}'")
def delete_room(self, scope: str):
"""Delete room.""" await db.commit()
if scope in self.rooms:
del self.rooms[scope] self._initialized = True
def get_last_horse_name(self, user_id: str) -> Optional[str]: async def ensure_initialized(self):
conn = sqlite3.connect(self.db_path) """Ensure database is initialized (call before any DB operation)."""
cursor = conn.cursor() if not self._initialized:
cursor.execute("SELECT horse_name FROM user_horse_names WHERE user_id = ?", (user_id,)) await self._init_db()
row = cursor.fetchone()
conn.close() def get_lock(self, scope: str) -> asyncio.Lock:
return row[0] if row else None """Get or create per-room lock."""
if scope not in self._locks:
def set_last_horse_name(self, user_id: str, horse_name: str): self._locks[scope] = asyncio.Lock()
conn = sqlite3.connect(self.db_path) return self._locks[scope]
cursor = conn.cursor()
cursor.execute( def get_room(self, scope: str) -> Optional[Room]:
"INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)", """Get room by scope."""
(user_id, horse_name), return self.rooms.get(scope)
)
conn.commit() async def create_room(self, scope: str) -> Room:
conn.close() """Create new room."""
room = Room(scope=scope)
def _save_snapshot(self, room: Room): self.rooms[scope] = room
"""Save room snapshot to database.""" await self._save_snapshot(room)
import json return room
horses_json = json.dumps({ def delete_room(self, scope: str):
name: { """Delete room."""
"owner_id": horse.owner_id, if scope in self.rooms:
"name": horse.name, del self.rooms[scope]
"index": horse.index,
"position": horse.position, async def get_last_horse_name(self, user_id: str) -> Optional[str]:
"state": horse.state.value, await self.ensure_initialized()
} async with aiosqlite.connect(self.db_path) as db:
for name, horse in room.horses.items() cursor = await db.execute(
}) "SELECT horse_name FROM user_horse_names WHERE user_id = ?",
(user_id,)
bets_json = json.dumps([ )
{ row = await cursor.fetchone()
"user_id": bet.user_id, return row[0] if row else None
"horse_name": bet.horse_name,
"amount": bet.amount, async def set_last_horse_name(self, user_id: str, horse_name: str):
} await self.ensure_initialized()
for bet in room.bets async with aiosqlite.connect(self.db_path) as db:
]) await db.execute(
"INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)",
conn = sqlite3.connect(self.db_path) (user_id, horse_name),
cursor = conn.cursor() )
cursor.execute(""" await db.commit()
INSERT OR REPLACE INTO room_snapshots
(scope, state, created_at, horses, bets, champion_name, tick_count) async def _save_snapshot(self, room: Room):
VALUES (?, ?, ?, ?, ?, ?, ?) """Save room snapshot to database."""
""", ( await self.ensure_initialized()
room.scope,
room.state.value, horses_json = json.dumps({
room.created_at.isoformat(), name: {
horses_json, "owner_id": horse.owner_id,
bets_json, "name": horse.name,
room.champion_name, "index": horse.index,
room.tick_count, "position": horse.position,
)) "state": horse.state.value,
conn.commit() }
conn.close() for name, horse in room.horses.items()
})
def save_race_result(self, result: RaceResult):
"""Save race result to history.""" bets_json = json.dumps([
import json {
"user_id": bet.user_id,
conn = sqlite3.connect(self.db_path) "horse_name": bet.horse_name,
cursor = conn.cursor() "amount": bet.amount,
cursor.execute(""" }
INSERT INTO race_history for bet in room.bets
(race_id, scope, champion_name, champion_owner, participants, bet_distribution, duration_ticks, completed_at) ])
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", ( async with aiosqlite.connect(self.db_path) as db:
result.race_id, await db.execute("""
result.scope, INSERT OR REPLACE INTO room_snapshots
result.champion_name, (scope, state, created_at, horses, bets, champion_name, tick_count)
result.champion_owner, VALUES (?, ?, ?, ?, ?, ?, ?)
json.dumps(result.participants), """, (
json.dumps(result.bet_distribution), room.scope,
result.duration_ticks, room.state.value,
result.completed_at.isoformat(), room.created_at.isoformat(),
)) horses_json,
conn.commit() bets_json,
conn.close() room.champion_name,
room.tick_count,
))
await db.commit()
async def save_race_result(self, result: RaceResult):
"""Save race result to history."""
await self.ensure_initialized()
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
INSERT INTO race_history
(race_id, scope, champion_name, champion_owner, participants,
bet_distribution, duration_ticks, completed_at,
point_changes, point_change_summaries, odds_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
result.race_id,
result.scope,
result.champion_name,
result.champion_owner,
json.dumps(result.participants),
json.dumps(result.bet_distribution),
result.duration_ticks,
result.completed_at.isoformat(),
json.dumps(getattr(result, 'point_changes', {})),
json.dumps(getattr(result, 'point_change_summaries', {})),
json.dumps(getattr(result, 'odds_snapshot', {})),
))
await db.commit()

View File

@@ -1,413 +1,413 @@
from nonebot import on_command from nonebot import on_command
from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, PrivateMessageEvent from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, PrivateMessageEvent
from . import plugin_config as config from . import plugin_config as config
from .commands import get_scope, check_access, room_store, points_service, race_engine from .commands import get_scope, check_access, race_engine
from .models import Horse, HorseState, RoomState, Bet, RaceResult from .models import Horse, HorseState, RoomState, Bet, RaceResult
import asyncio import asyncio
import random import random
from datetime import datetime from datetime import datetime
import traceback import traceback
from . import commands as commands_mod from . import commands as commands_mod
async def check_tester(event: Event) -> bool: async def check_tester(event: Event) -> bool:
"""Check if user is a tester.""" """Check if user is a tester."""
if not config.TEST_MODE: if not config.TEST_MODE:
return False return False
return event.user_id in config.TESTERS return event.user_id in config.TESTERS
test_reset_points_cmd = on_command("测试重置积分", priority=5) test_reset_points_cmd = on_command("测试重置积分", priority=5)
@test_reset_points_cmd.handle() @test_reset_points_cmd.handle()
async def handle_test_reset_points(bot: Bot, event: Event): async def handle_test_reset_points(bot: Bot, event: Event):
"""Reset user points to 1000 for testing.""" """Reset user points to 1000 for testing."""
if not await check_tester(event): if not await check_tester(event):
await test_reset_points_cmd.finish("权限不足") await test_reset_points_cmd.finish("权限不足")
return return
success, _ = await points_service.set_points(event.user_id, 1000, "测试重置积分") success, _ = await commands_mod.points_service.set_points(event.user_id, 1000, "测试重置积分")
if success: if success:
await test_reset_points_cmd.finish("积分已重置为1000") await test_reset_points_cmd.finish("积分已重置为1000")
else: else:
await test_reset_points_cmd.finish("重置失败") await test_reset_points_cmd.finish("重置失败")
test_set_points_cmd = on_command("测试设置积分", priority=5) test_set_points_cmd = on_command("测试设置积分", priority=5)
@test_set_points_cmd.handle() @test_set_points_cmd.handle()
async def handle_test_set_points(bot: Bot, event: Event): async def handle_test_set_points(bot: Bot, event: Event):
"""Set user points for testing.""" """Set user points for testing."""
if not await check_tester(event): if not await check_tester(event):
await test_set_points_cmd.finish("权限不足") await test_set_points_cmd.finish("权限不足")
return return
# Get the message text and extract amount # Get the message text and extract amount
msg = str(event.get_message()).strip() msg = str(event.get_message()).strip()
# Remove command prefix # Remove command prefix
parts = msg.split() parts = msg.split()
if len(parts) < 2: if len(parts) < 2:
await test_set_points_cmd.finish("请使用: /测试设置积分 <金额>") await test_set_points_cmd.finish("请使用: /测试设置积分 <金额>")
return return
try: try:
amount = int(parts[1]) amount = int(parts[1])
if amount < 0: if amount < 0:
await test_set_points_cmd.finish("金额必须为非负数") await test_set_points_cmd.finish("金额必须为非负数")
return return
except ValueError: except ValueError:
await test_set_points_cmd.finish("金额必须是整数") await test_set_points_cmd.finish("金额必须是整数")
return return
success, _ = await points_service.set_points(event.user_id, amount, f"测试设置积分为{amount}") success, _ = await commands_mod.points_service.set_points(event.user_id, amount, f"测试设置积分为{amount}")
if success: if success:
await test_set_points_cmd.finish(f"积分已设置为 {amount}") await test_set_points_cmd.finish(f"积分已设置为 {amount}")
else: else:
await test_set_points_cmd.finish("设置失败") await test_set_points_cmd.finish("设置失败")
test_query_points_cmd = on_command("测试查询积分", priority=5) test_query_points_cmd = on_command("测试查询积分", priority=5)
@test_query_points_cmd.handle() @test_query_points_cmd.handle()
async def handle_test_query_points(bot: Bot, event: Event): async def handle_test_query_points(bot: Bot, event: Event):
"""Query user points for testing.""" """Query user points for testing."""
if not await check_tester(event): if not await check_tester(event):
await test_query_points_cmd.finish("权限不足") await test_query_points_cmd.finish("权限不足")
return return
balance = await points_service.get_balance(event.user_id) balance = await commands_mod.points_service.get_balance(event.user_id)
await test_query_points_cmd.finish(f"当前积分: {balance}") await test_query_points_cmd.finish(f"当前积分: {balance}")
test_clear_room_cmd = on_command("测试清空房间", priority=5) test_clear_room_cmd = on_command("测试清空房间", priority=5)
@test_clear_room_cmd.handle() @test_clear_room_cmd.handle()
async def handle_test_clear_room(bot: Bot, event: Event): async def handle_test_clear_room(bot: Bot, event: Event):
"""Clear test room.""" """Clear test room."""
if not await check_tester(event): if not await check_tester(event):
await test_clear_room_cmd.finish("权限不足") await test_clear_room_cmd.finish("权限不足")
return return
scope = get_scope(event) scope = get_scope(event)
room_store.delete_room(scope) commands_mod.room_store.delete_room(scope)
await test_clear_room_cmd.finish("房间已清空") await test_clear_room_cmd.finish("房间已清空")
test_force_start_cmd = on_command("测试强制开赛", priority=5) test_force_start_cmd = on_command("测试强制开赛", priority=5)
@test_force_start_cmd.handle() @test_force_start_cmd.handle()
async def handle_test_force_start(bot: Bot, event: Event): async def handle_test_force_start(bot: Bot, event: Event):
"""Force start race for testing.""" """Force start race for testing."""
if not await check_tester(event): if not await check_tester(event):
await test_force_start_cmd.finish("权限不足") await test_force_start_cmd.finish("权限不足")
return return
await test_force_start_cmd.finish("测试强制开赛命令") await test_force_start_cmd.finish("测试强制开赛命令")
def _generate_random_horse_names(count: int) -> list[str]: def _generate_random_horse_names(count: int) -> list[str]:
prefixes = ["赤焰", "踏雪", "追风", "流星", "疾电", "破晓", "青岚", "玄影", "星尘", "霜刃", "烈阳", "苍穹"] prefixes = ["赤焰", "踏雪", "追风", "流星", "疾电", "破晓", "青岚", "玄影", "星尘", "霜刃", "烈阳", "苍穹"]
cores = ["", "", "", "", "", "", "", "", "", "", "", ""] cores = ["", "", "", "", "", "", "", "", "", "", "", ""]
suffixes = ["", "", "", "", "", "", "", "", "", ""] suffixes = ["", "", "", "", "", "", "", "", "", ""]
names: set[str] = set() names: set[str] = set()
attempts = 0 attempts = 0
while len(names) < count and attempts < 500: while len(names) < count and attempts < 500:
attempts += 1 attempts += 1
name = f"{random.choice(prefixes)}{random.choice(cores)}{random.choice(suffixes)}" name = f"{random.choice(prefixes)}{random.choice(cores)}{random.choice(suffixes)}"
if len(name) > 10: if len(name) > 10:
name = name[:10] name = name[:10]
names.add(name) names.add(name)
while len(names) < count: while len(names) < count:
names.add(f"测试马{len(names) + 1}") names.add(f"测试马{len(names) + 1}")
return list(names)[:count] return list(names)[:count]
test_simulate_race_cmd = on_command("测试模拟赛马", aliases={"测试模拟"}, priority=1, block=True) test_simulate_race_cmd = on_command("测试模拟赛马", aliases={"测试模拟"}, priority=1, block=True)
class _FakeBot: class _FakeBot:
def __init__(self): def __init__(self):
self.messages: list[dict] = [] self.messages: list[dict] = []
self._next_message_id = 1 self._next_message_id = 1
async def send_msg(self, **kwargs): async def send_msg(self, **kwargs):
self.messages.append(dict(kwargs)) self.messages.append(dict(kwargs))
message_id = self._next_message_id message_id = self._next_message_id
self._next_message_id += 1 self._next_message_id += 1
return {"message_id": message_id} return {"message_id": message_id}
async def delete_msg(self, message_id: int): async def delete_msg(self, message_id: int):
# Simply record the deletion if needed, or do nothing # Simply record the deletion if needed, or do nothing
return return
class _InMemoryRoomStore: class _InMemoryRoomStore:
def __init__(self): def __init__(self):
self.rooms: dict[str, "commands_mod.Room"] = {} self.rooms: dict[str, "commands_mod.Room"] = {}
self.saved_results: list[RaceResult] = [] self.saved_results: list[RaceResult] = []
def get_room(self, scope: str): def get_room(self, scope: str):
return self.rooms.get(scope) return self.rooms.get(scope)
def create_room(self, scope: str): def create_room(self, scope: str):
room = commands_mod.Room(scope=scope) room = commands_mod.Room(scope=scope)
self.rooms[scope] = room self.rooms[scope] = room
return room return room
def delete_room(self, scope: str): def delete_room(self, scope: str):
if scope in self.rooms: if scope in self.rooms:
del self.rooms[scope] del self.rooms[scope]
def save_race_result(self, result: RaceResult): def save_race_result(self, result: RaceResult):
self.saved_results.append(result) self.saved_results.append(result)
class _InMemoryPointsService: class _InMemoryPointsService:
def __init__(self): def __init__(self):
self.calls: list[tuple[str, dict]] = [] self.calls: list[tuple[str, dict]] = []
async def reward_champion(self, user_id: str): async def reward_champion(self, user_id: str):
self.calls.append(("reward_champion", {"user_id": user_id})) self.calls.append(("reward_champion", {"user_id": user_id}))
return True, 0 return True, 0
async def reward_participant(self, user_id: str): async def reward_participant(self, user_id: str):
self.calls.append(("reward_participant", {"user_id": user_id})) self.calls.append(("reward_participant", {"user_id": user_id}))
return True, 0 return True, 0
async def payout_winnings(self, user_id: str, amount: int, odds: float): async def payout_winnings(self, user_id: str, amount: int, odds: float):
self.calls.append(("payout_winnings", {"user_id": user_id, "amount": amount, "odds": odds})) self.calls.append(("payout_winnings", {"user_id": user_id, "amount": amount, "odds": odds}))
return True, 0 return True, 0
async def refund_bet_points(self, user_id: str, amount: int, reason: str = "比赛中断退还"): async def refund_bet_points(self, user_id: str, amount: int, reason: str = "比赛中断退还"):
self.calls.append(("refund_bet_points", {"user_id": user_id, "amount": amount, "reason": reason})) self.calls.append(("refund_bet_points", {"user_id": user_id, "amount": amount, "reason": reason}))
return True, 0 return True, 0
async def get_balance(self, user_id: str) -> int: async def get_balance(self, user_id: str) -> int:
self.calls.append(("get_balance", {"user_id": user_id})) self.calls.append(("get_balance", {"user_id": user_id}))
return 8888 return 8888
class _NoopMessageService: class _NoopMessageService:
def __init__(self): def __init__(self):
self.last_messages: dict[str, dict[str, str]] = {} self.last_messages: dict[str, dict[str, str]] = {}
def clear_pending_recalls(self, scope: str): def clear_pending_recalls(self, scope: str):
if scope in self.last_messages: if scope in self.last_messages:
del self.last_messages[scope] del self.last_messages[scope]
async def send_with_recall(self, bot, scope, message_type, message): async def send_with_recall(self, bot, scope, message_type, message):
# Support basic recall for race_update to avoid flooding during simulation # Support basic recall for race_update to avoid flooding during simulation
if message_type == "race_update": if message_type == "race_update":
await self.recall_previous_of_type(bot, scope, "race_update") await self.recall_previous_of_type(bot, scope, "race_update")
is_group = scope.startswith("group_") is_group = scope.startswith("group_")
result = await bot.send_msg( result = await bot.send_msg(
message_type="group" if is_group else "private", message_type="group" if is_group else "private",
group_id=int(scope.split("_", 1)[1]) if is_group else None, group_id=int(scope.split("_", 1)[1]) if is_group else None,
user_id=int(scope.split("_", 1)[1]) if not is_group else None, user_id=int(scope.split("_", 1)[1]) if not is_group else None,
message=message, message=message,
) )
if scope not in self.last_messages: if scope not in self.last_messages:
self.last_messages[scope] = {} self.last_messages[scope] = {}
if isinstance(result, dict) and "message_id" in result: if isinstance(result, dict) and "message_id" in result:
self.last_messages[scope][message_type] = result["message_id"] self.last_messages[scope][message_type] = result["message_id"]
return "fake_msg_id" return "fake_msg_id"
async def recall_previous_of_type(self, bot, scope, message_type): async def recall_previous_of_type(self, bot, scope, message_type):
if scope in self.last_messages and message_type in self.last_messages[scope]: if scope in self.last_messages and message_type in self.last_messages[scope]:
msg_id = self.last_messages[scope][message_type] msg_id = self.last_messages[scope][message_type]
try: try:
await bot.delete_msg(message_id=msg_id) await bot.delete_msg(message_id=msg_id)
except Exception: except Exception:
pass pass
del self.last_messages[scope][message_type] del self.last_messages[scope][message_type]
@test_simulate_race_cmd.handle() @test_simulate_race_cmd.handle()
async def handle_test_simulate_race(bot: Bot, event: Event): async def handle_test_simulate_race(bot: Bot, event: Event):
if not await check_tester(event): if not await check_tester(event):
await test_simulate_race_cmd.send("权限不足") await test_simulate_race_cmd.send("权限不足")
return return
await test_simulate_race_cmd.send("收到:测试模拟赛马,开始执行完全模拟(无真实积分/数据库副作用)") await test_simulate_race_cmd.send("收到:测试模拟赛马,开始执行完全模拟(无真实积分/数据库副作用)")
raw_msg = str(event.get_message()).strip() raw_msg = str(event.get_message()).strip()
stream_progress = ("展示" in raw_msg) or ("实时" in raw_msg) or ("" in raw_msg) stream_progress = ("展示" in raw_msg) or ("实时" in raw_msg) or ("" in raw_msg)
scope = get_scope(event) scope = get_scope(event)
try: try:
race_engine.stop_race(scope) race_engine.stop_race(scope)
room_store.delete_room(scope) await commands_mod.room_store.delete_room(scope)
except Exception: except Exception:
pass pass
original_room_store = commands_mod.room_store original_room_store = commands_mod.room_store
original_points_service = commands_mod.points_service original_points_service = commands_mod.points_service
original_message_service = commands_mod.message_service original_message_service = commands_mod.message_service
original_tick_interval = commands_mod.config.RACE_TICK_INTERVAL original_tick_interval = commands_mod.config.RACE_TICK_INTERVAL
original_stop_race = commands_mod.race_engine.stop_race original_stop_race = commands_mod.race_engine.stop_race
original_send_to_scope = commands_mod._send_to_scope original_send_to_scope = commands_mod._send_to_scope
fake_room_store = _InMemoryRoomStore() fake_room_store = _InMemoryRoomStore()
fake_points_service = _InMemoryPointsService() fake_points_service = _InMemoryPointsService()
fake_message_service = _NoopMessageService() fake_message_service = _NoopMessageService()
fake_bot = _FakeBot() fake_bot = _FakeBot()
start_task: asyncio.Task | None = None start_task: asyncio.Task | None = None
room = None room = None
try: try:
await test_simulate_race_cmd.send("阶段:构建沙盒环境与参赛数据") await test_simulate_race_cmd.send("阶段:构建沙盒环境与参赛数据")
commands_mod.room_store = fake_room_store commands_mod.room_store = fake_room_store
commands_mod.points_service = fake_points_service commands_mod.points_service = fake_points_service
commands_mod.message_service = fake_message_service commands_mod.message_service = fake_message_service
commands_mod.config.RACE_TICK_INTERVAL = 1 if stream_progress else 0 commands_mod.config.RACE_TICK_INTERVAL = 1 if stream_progress else 0
commands_mod.race_engine.stop_race = lambda _scope: commands_mod.race_engine.active_tasks.pop(_scope, None) commands_mod.race_engine.stop_race = lambda _scope: commands_mod.race_engine.active_tasks.pop(_scope, None)
progress_count = 0 progress_count = 0
max_progress = 30 max_progress = 30
async def _test_send_to_scope(_bot: Bot, _scope: str, message: str, *args, **kwargs): async def _test_send_to_scope(_bot: Bot, _scope: str, message: str, *args, **kwargs):
nonlocal progress_count nonlocal progress_count
await fake_bot.send_msg(message_type="private", user_id=event.user_id, message=message) await fake_bot.send_msg(message_type="private", user_id=event.user_id, message=message)
if not stream_progress: if not stream_progress:
return return
if message.startswith("【第") and "回合】" in message: if message.startswith("【第") and "回合】" in message:
progress_count += 1 progress_count += 1
if progress_count > max_progress: if progress_count > max_progress:
return return
await original_send_to_scope(bot, scope, message, *args, **kwargs) await original_send_to_scope(bot, scope, message, *args, **kwargs)
commands_mod._send_to_scope = _test_send_to_scope commands_mod._send_to_scope = _test_send_to_scope
room = fake_room_store.create_room(scope) room = fake_room_store.create_room(scope)
horse_names = _generate_random_horse_names(8) horse_names = _generate_random_horse_names(8)
for idx, horse_name in enumerate(horse_names, start=1): for idx, horse_name in enumerate(horse_names, start=1):
owner_id = f"sim_user_{idx}" owner_id = f"sim_user_{idx}"
room.horses[horse_name] = Horse(owner_id=owner_id, name=horse_name, index=idx, state=HorseState.RACING) room.horses[horse_name] = Horse(owner_id=owner_id, name=horse_name, index=idx, state=HorseState.RACING)
room.next_horse_index = len(horse_names) + 1 room.next_horse_index = len(horse_names) + 1
bet_amount = max(commands_mod.config.MIN_BET, 10) bet_amount = max(commands_mod.config.MIN_BET, 10)
room.bets.append(Bet(user_id="bettor_1", horse_name=horse_names[0], amount=bet_amount)) room.bets.append(Bet(user_id="bettor_1", horse_name=horse_names[0], amount=bet_amount))
room.bets.append(Bet(user_id="bettor_2", horse_name=horse_names[1], amount=bet_amount * 2)) room.bets.append(Bet(user_id="bettor_2", horse_name=horse_names[1], amount=bet_amount * 2))
room.state = RoomState.WAITING room.state = RoomState.WAITING
for horse in room.horses.values(): for horse in room.horses.values():
horse.state = HorseState.RACING horse.state = HorseState.RACING
await test_simulate_race_cmd.send("阶段:执行赛程(后台任务)") await test_simulate_race_cmd.send("阶段:执行赛程(后台任务)")
start_task = asyncio.create_task(commands_mod.run_race_with_settlement(fake_bot, room, scope)) start_task = asyncio.create_task(commands_mod.run_race_with_settlement(fake_bot, room, scope))
commands_mod.race_engine.register_task(scope, start_task) commands_mod.race_engine.register_task(scope, start_task)
await asyncio.wait_for(start_task, timeout=180 if stream_progress else 30) await asyncio.wait_for(start_task, timeout=180 if stream_progress else 30)
messages = [str(m.get("message", "")) for m in fake_bot.messages] messages = [str(m.get("message", "")) for m in fake_bot.messages]
if not messages: if not messages:
await test_simulate_race_cmd.send("完全模拟失败:未捕获到任何消息") await test_simulate_race_cmd.send("完全模拟失败:未捕获到任何消息")
return return
if not any("比赛开始!" in msg for msg in messages): if not any("比赛开始!" in msg for msg in messages):
await test_simulate_race_cmd.send("完全模拟失败:未发送开赛消息") await test_simulate_race_cmd.send("完全模拟失败:未发送开赛消息")
return return
# Look for the start message to verify horse names # Look for the start message to verify horse names
start_msg = next((msg for msg in messages if "比赛开始!" in msg), "") start_msg = next((msg for msg in messages if "比赛开始!" in msg), "")
for idx, horse_name in enumerate(horse_names, start=1): for idx, horse_name in enumerate(horse_names, start=1):
if f"{idx:02d}{horse_name}" not in start_msg: if f"{idx:02d}{horse_name}" not in start_msg:
await test_simulate_race_cmd.send(f"完全模拟失败:开赛名单中未找到 {idx:02d}{horse_name}") await test_simulate_race_cmd.send(f"完全模拟失败:开赛名单中未找到 {idx:02d}{horse_name}")
return return
progress_messages = [msg for msg in messages if "【第" in msg and "回合】" in msg] progress_messages = [msg for msg in messages if "【第" in msg and "回合】" in msg]
if not progress_messages: if not progress_messages:
await test_simulate_race_cmd.send("完全模拟失败:未发送回合进度消息") await test_simulate_race_cmd.send("完全模拟失败:未发送回合进度消息")
return return
# Check first progress message format # Check first progress message format
progress_lines = [line for line in progress_messages[0].splitlines() if "|" in line] progress_lines = [line for line in progress_messages[0].splitlines() if "|" in line]
if len(progress_lines) != len(horse_names): if len(progress_lines) != len(horse_names):
await test_simulate_race_cmd.send("完全模拟失败:回合进度展示条目数量不匹配") await test_simulate_race_cmd.send("完全模拟失败:回合进度展示条目数量不匹配")
return return
if not any("比赛结束!冠军:" in msg for msg in messages): if not any("比赛结束!冠军:" in msg for msg in messages):
await test_simulate_race_cmd.send("完全模拟失败:未发送结束结算消息") await test_simulate_race_cmd.send("完全模拟失败:未发送结束结算消息")
return return
if not any("积分变化:" in msg for msg in messages): if not any("积分变化:" in msg for msg in messages):
await test_simulate_race_cmd.send("完全模拟失败:未发送积分变化总结") await test_simulate_race_cmd.send("完全模拟失败:未发送积分变化总结")
return return
if not fake_room_store.saved_results: if not fake_room_store.saved_results:
await test_simulate_race_cmd.send("完全模拟失败:未写入赛史结果(内存)") await test_simulate_race_cmd.send("完全模拟失败:未写入赛史结果(内存)")
return return
saved = fake_room_store.saved_results[-1] saved = fake_room_store.saved_results[-1]
if saved.champion_name not in room.horses: if saved.champion_name not in room.horses:
await test_simulate_race_cmd.send("完全模拟失败:赛史冠军不在参赛马匹中") await test_simulate_race_cmd.send("完全模拟失败:赛史冠军不在参赛马匹中")
return return
if not saved.point_changes: if not saved.point_changes:
await test_simulate_race_cmd.send("完全模拟失败:赛史未记录积分变化") await test_simulate_race_cmd.send("完全模拟失败:赛史未记录积分变化")
return return
if not saved.point_change_summaries: if not saved.point_change_summaries:
await test_simulate_race_cmd.send("完全模拟失败:赛史未记录积分变化总结") await test_simulate_race_cmd.send("完全模拟失败:赛史未记录积分变化总结")
return return
champion_owner_id = room.horses[saved.champion_name].owner_id champion_owner_id = room.horses[saved.champion_name].owner_id
reward_champion_calls = [c for c in fake_points_service.calls if c[0] == "reward_champion"] reward_champion_calls = [c for c in fake_points_service.calls if c[0] == "reward_champion"]
if not reward_champion_calls or reward_champion_calls[0][1]["user_id"] != champion_owner_id: if not reward_champion_calls or reward_champion_calls[0][1]["user_id"] != champion_owner_id:
await test_simulate_race_cmd.send("完全模拟失败:未正确发放冠军奖励(内存记录)") await test_simulate_race_cmd.send("完全模拟失败:未正确发放冠军奖励(内存记录)")
return return
participant_calls = [c for c in fake_points_service.calls if c[0] == "reward_participant"] participant_calls = [c for c in fake_points_service.calls if c[0] == "reward_participant"]
if len(participant_calls) != len(room.horses): if len(participant_calls) != len(room.horses):
await test_simulate_race_cmd.send("完全模拟失败:参赛奖励次数不匹配(内存记录)") await test_simulate_race_cmd.send("完全模拟失败:参赛奖励次数不匹配(内存记录)")
return return
await test_simulate_race_cmd.send( await test_simulate_race_cmd.send(
"\n".join( "\n".join(
[ [
"完全模拟赛马完成(无真实积分/数据库副作用)", "完全模拟赛马完成(无真实积分/数据库副作用)",
f"参赛马匹:{', '.join(horse_names)}", f"参赛马匹:{', '.join(horse_names)}",
f"冠军:{saved.champion_name}(马主:{saved.champion_owner}", f"冠军:{saved.champion_name}(马主:{saved.champion_owner}",
f"总回合:{saved.duration_ticks}", f"总回合:{saved.duration_ticks}",
f"消息条数:{len(messages)}(开赛/进度/结算均已覆盖)", f"消息条数:{len(messages)}(开赛/进度/结算均已覆盖)",
f"结算调用:{len(fake_points_service.calls)}(冠军/参赛/下注派奖)", f"结算调用:{len(fake_points_service.calls)}(冠军/参赛/下注派奖)",
f"积分变化用户数:{len(saved.point_changes)}", f"积分变化用户数:{len(saved.point_changes)}",
f"过程展示:{'开启' if stream_progress else '关闭'}", f"过程展示:{'开启' if stream_progress else '关闭'}",
] ]
) )
) )
return return
except asyncio.TimeoutError: except asyncio.TimeoutError:
ticks = room.tick_count if room else 0 ticks = room.tick_count if room else 0
await test_simulate_race_cmd.send(f"完全模拟失败:超时未完成(当前回合:{ticks}") await test_simulate_race_cmd.send(f"完全模拟失败:超时未完成(当前回合:{ticks}")
except asyncio.CancelledError: except asyncio.CancelledError:
ticks = room.tick_count if room else 0 ticks = room.tick_count if room else 0
await test_simulate_race_cmd.send(f"完全模拟失败:任务被取消(当前回合:{ticks}") await test_simulate_race_cmd.send(f"完全模拟失败:任务被取消(当前回合:{ticks}")
except Exception as e: except Exception as e:
tail = "\n".join(traceback.format_exc().splitlines()[-8:]) tail = "\n".join(traceback.format_exc().splitlines()[-8:])
await test_simulate_race_cmd.send(f"完全模拟异常:{type(e).__name__}: {e}\n{tail}") await test_simulate_race_cmd.send(f"完全模拟异常:{type(e).__name__}: {e}\n{tail}")
finally: finally:
if start_task and not start_task.done(): if start_task and not start_task.done():
start_task.cancel() start_task.cancel()
commands_mod.room_store = original_room_store commands_mod.room_store = original_room_store
commands_mod.points_service = original_points_service commands_mod.points_service = original_points_service
commands_mod.message_service = original_message_service commands_mod.message_service = original_message_service
commands_mod.config.RACE_TICK_INTERVAL = original_tick_interval commands_mod.config.RACE_TICK_INTERVAL = original_tick_interval
commands_mod.race_engine.stop_race = original_stop_race commands_mod.race_engine.stop_race = original_stop_race
commands_mod._send_to_scope = original_send_to_scope commands_mod._send_to_scope = original_send_to_scope