feat(赛马): 为马匹添加序号并优化积分结算展示

- 为 Horse 模型添加 index 字段,用于唯一标识马匹序号
- 在报名时自动分配递增序号,并在所有展示中使用固定序号排序
- 新增积分变化计算功能,在比赛结果中展示每位用户的积分变化和总结描述
- 支持通过序号或马匹名下注,优化用户交互体验
- 添加用户上次马名记忆功能,允许重复使用马名报名
- 更新测试用例以验证序号展示和积分变化功能
This commit is contained in:
2026-04-04 22:43:46 +08:00
parent 2214e22b80
commit 64020cb0e6
5 changed files with 190 additions and 56 deletions

View File

@@ -84,6 +84,108 @@ def get_event_id(event: Event) -> str:
return str(event.user_id)
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 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 horse in room.horses.values():
if champion and horse.owner_id != champion.owner_id:
point_changes[horse.owner_id] = point_changes.get(horse.owner_id, 0) + config.PARTICIPANT_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 _format_point_change_lines(room: Room, point_changes: dict[str, int], point_summaries: 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))
lines.append(f" {user_id} {delta:+d} 积分 · {summary}")
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)
@@ -98,11 +200,11 @@ def calculate_odds(room: Room) -> dict[str, float]:
return odds
async def settle_race(room: Room):
async def settle_race(room: Room) -> RaceResult | None:
"""Settle bets and rewards after race finishes."""
champion = room.horses.get(room.champion_name)
if not champion:
return
return None
# Reward champion owner
await points_service.reward_champion(champion.owner_id)
@@ -119,17 +221,21 @@ async def settle_race(room: Room):
await points_service.payout_winnings(bet.user_id, bet.amount, odds.get(bet.horse_name, config.MIN_ODDS))
# Save race result
point_changes, point_summaries = _build_point_changes(room, odds)
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()],
participants=[h.name for h in _get_horses_in_order(room)],
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(),
point_changes=point_changes,
point_change_summaries=point_summaries,
)
room_store.save_race_result(result)
return result
async def _send_to_scope(bot: Bot, scope: str, message: str):
@@ -152,8 +258,7 @@ 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())
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 loop with progress updates
@@ -177,13 +282,12 @@ async def run_race_with_settlement(bot: Bot, room: Room, scope: str):
return
# Settle
await settle_race(room)
result = 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"比赛结束!冠军:{_format_horse_label(champion) if champion else 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]
@@ -192,6 +296,8 @@ async def run_race_with_settlement(bot: Bot, room: Room, scope: str):
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}")
if result:
result_lines.extend(_format_point_change_lines(room, result.point_changes, result.point_change_summaries))
await _send_to_scope(bot, scope, "\n".join(result_lines))
@@ -213,13 +319,13 @@ async def handle_register(bot: Bot, event: 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 ""
user_id = get_event_id(event)
horse_name = parts[1].strip() if len(parts) > 1 else room_store.get_last_horse_name(user_id) or ""
if not horse_name:
await register_cmd.finish("请输入马匹名:/赛马报名 <马匹名>")
await register_cmd.finish("请输入马匹名:/赛马报名 <马匹名>。若你之前报过名,也可以直接发送 /赛马报名 复用上一次绑定的马名")
return
if len(horse_name) > 10:
@@ -227,7 +333,6 @@ async def handle_register(bot: Bot, event: Event):
return
scope = get_scope(event)
user_id = get_event_id(event)
lock = room_store.get_lock(scope)
async with lock:
@@ -243,21 +348,24 @@ async def handle_register(bot: Bot, event: Event):
await register_cmd.finish("房间已满最多8匹马")
return
if horse_name in room.horses:
await register_cmd.finish(f"马匹名 \"{horse_name}\" 已被使用")
duplicate_horse = _find_duplicate_horse(room, horse_name)
if duplicate_horse:
await register_cmd.finish(f"马匹名 \"{horse_name}\" 已被 {_format_horse_label(duplicate_horse)} 使用")
return
# Check if user already registered
for h in room.horses.values():
if h.owner_id == user_id:
await register_cmd.finish("你已经报名了,不能重复报名")
return
existing_user_horse = _find_user_horse(room, user_id)
if existing_user_horse:
await register_cmd.finish(f"你已经报名了,当前马匹为 {_format_horse_label(existing_user_horse)}")
return
# Create horse
room.horses[horse_name] = Horse(owner_id=user_id, name=horse_name)
horse_index = room.next_horse_index
room.next_horse_index += 1
room.horses[horse_name] = Horse(owner_id=user_id, name=horse_name, index=horse_index)
room_store.set_last_horse_name(user_id, horse_name)
count = len(room.horses)
await register_cmd.finish(f"报名成功!马匹 \"{horse_name}\" 已加入比赛({count}/8")
registered_horse = room.horses[horse_name]
await register_cmd.finish(f"报名成功!{_format_horse_label(registered_horse)} 已加入比赛({count}/8")
cancel_cmd = on_command("赛马取消报名", priority=5)
@@ -284,27 +392,19 @@ async def handle_cancel(bot: Bot, event: Event):
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
user_horse = _find_user_horse(room, user_id)
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]
bets_to_refund = [b for b in room.bets if b.horse_name == user_horse.name]
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]
room.bets = [b for b in room.bets if b.horse_name != user_horse.name]
# Remove horse
del room.horses[user_horse]
del room.horses[user_horse.name]
await cancel_cmd.finish(f"已取消报名,马匹 \"{user_horse}\" 已退出")
await cancel_cmd.finish(f"已取消报名,{_format_horse_label(user_horse)} 已退出")
bet_cmd = on_command("赛马下注", priority=5)
@@ -317,14 +417,13 @@ async def handle_bet(bot: Bot, event: 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("请使用:/赛马下注 <马匹名> <金额>")
await bet_cmd.finish("请使用:/赛马下注 <序号|马匹名> <金额>")
return
horse_name = parts[1]
horse_selector = parts[1]
try:
amount = int(parts[2])
except ValueError:
@@ -349,26 +448,24 @@ async def handle_bet(bot: Bot, event: Event):
await bet_cmd.finish("比赛正在进行中,无法下注")
return
if horse_name not in room.horses:
await bet_cmd.finish(f"马匹 \"{horse_name}\" 不存在")
horse = _resolve_horse_selector(room, horse_selector)
if not horse:
await bet_cmd.finish(f"马匹序号/名称 \"{horse_selector}\" 不存在")
return
# Can't bet on your own horse
if room.horses[horse_name].owner_id == user_id:
if horse.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}")
success, balance = await points_service.spend_bet_points(user_id, amount, f"下注 {_format_horse_label(horse)}")
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))
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}")
await bet_cmd.finish(f"下注成功!{_format_horse_label(horse)} {amount}积分(当前赔率:{odds.get(horse.name, config.MIN_ODDS):.2f}")
odds_cmd = on_command("赛马赔率", priority=5)
@@ -394,9 +491,10 @@ async def handle_odds(bot: Bot, event: Event):
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})")
for horse in _get_horses_in_order(room):
odd = odds.get(horse.name, config.MIN_ODDS)
horse_bet = sum(b.amount for b in room.bets if b.horse_name == horse.name)
lines.append(f" {_format_horse_label(horse)} - {odd:.2f}倍 (总下注: {horse_bet})")
lines.append(f"总下注池: {total_bet}")
await odds_cmd.finish("\n".join(lines))
@@ -449,7 +547,8 @@ async def handle_help(bot: Bot, event: Event):
help_text = """赛马命令帮助:
/赛马报名 <马匹名> - 报名参赛
/赛马取消报名 - 取消报名并退还下注
/赛马下注 <马匹名> <金额> - 下注(不能给自己的马下注)
/赛马报名 - 复用上一次绑定的马名报名
/赛马下注 <序号|马匹名> <金额> - 下注(不能给自己的马下注)
/赛马赔率 - 查看当前赔率
/赛马开赛 - 开始比赛至少2匹马
/赛马帮助 - 显示此帮助"""