diff --git a/.tasks/2025-10-28_1_add-idiom-chain-game.md b/.tasks/2025-10-28_1_add-idiom-chain-game.md new file mode 100644 index 0000000..e515da6 --- /dev/null +++ b/.tasks/2025-10-28_1_add-idiom-chain-game.md @@ -0,0 +1,238 @@ +# 背景 +文件名:2025-10-28_1_add-idiom-chain-game.md +创建于:2025-10-28_15:43:00 +创建者:admin +主分支:main +任务分支:task/add-idiom-chain-game_2025-10-28_1 +Yolo模式:Off + +# 任务描述 +在WPS Bot Game项目中新增一个成语接龙游戏功能。 + +## 核心需求 +1. 群内多人游戏,机器人作为裁判和出题者 +2. 允许按拼音接龙(包括谐音接龙) +3. 没有时间限制 +4. 不需要提示功能 +5. 游戏记录保存到.stats统计中 +6. 不允许重复使用成语 +7. 不需要难度分级(非人机对战) +8. 需要裁判指令用于接受/拒绝玩家回答 + +## 游戏玩法 +- 机器人出题(给出起始成语) +- 群内玩家轮流接龙 +- 机器人判断接龙是否有效(拼音/谐音匹配、未重复使用) +- 裁判可以手动接受或拒绝某个回答 +- 记录每个玩家的成功接龙次数 + +# 项目概览 + +## 项目结构 +``` +WPSBotGame/ +├── app.py # FastAPI主应用 +├── config.py # 配置管理 +├── core/ +│ ├── database.py # SQLite数据库操作 +│ ├── middleware.py # 中间件 +│ └── models.py # 数据模型 +├── games/ # 游戏模块 +│ ├── base.py # 游戏基类 +│ ├── dice.py # 骰娘游戏 +│ ├── rps.py # 石头剪刀布 +│ ├── fortune.py # 运势占卜 +│ ├── guess.py # 猜数字 +│ └── quiz.py # 问答游戏 +├── data/ # 数据文件 +│ ├── bot.db # SQLite数据库 +│ ├── quiz.json # 问答题库 +│ └── fortunes.json # 运势数据 +├── routers/ # 路由处理 +│ ├── callback.py # WPS回调处理 +│ └── health.py # 健康检查 +└── utils/ # 工具模块 + ├── message.py # 消息发送 + ├── parser.py # 指令解析 + └── rate_limit.py # 限流控制 +``` + +## 技术栈 +- FastAPI:Web框架 +- SQLite:数据存储 +- WPS协作机器人API:消息接收与发送 + +## 现有游戏架构 +1. 所有游戏继承`BaseGame`基类 +2. 必须实现`handle(command, chat_id, user_id)`方法处理指令 +3. 必须实现`get_help()`方法返回帮助信息 +4. 游戏状态存储在数据库`game_states`表:`(chat_id, user_id, game_type)`作为联合主键 +5. 游戏统计存储在`game_stats`表:记录`wins`, `losses`, `draws`, `total_plays` +6. 指令通过`CommandParser`解析,在`callback.py`中分发到对应游戏处理器 + +## 数据库设计 +### game_states表 +- chat_id: 会话ID +- user_id: 用户ID +- game_type: 游戏类型 +- state_data: JSON格式的游戏状态数据 +- created_at/updated_at: 时间戳 + +### game_stats表 +- user_id: 用户ID +- game_type: 游戏类型 +- wins/losses/draws/total_plays: 统计数据 + +# 分析 + +## 关键技术挑战 + +### 1. 群级别vs个人级别状态管理 +现有游戏(猜数字、问答)都是个人独立状态,使用`(chat_id, user_id, game_type)`作为主键。 + +成语接龙是群内共享游戏,需要: +- 群级别的游戏状态:当前成语、已用成语列表、接龙长度、当前轮到谁 +- 个人级别的统计:每个玩家的成功接龙次数 + +**可能方案:** +- 使用特殊user_id(如0或-1)存储群级别游戏状态 +- 或者在state_data中存储所有玩家信息 + +### 2. 成语词库准备 +需要准备: +- 成语列表(至少500-1000个常用成语) +- 每个成语的拼音信息(用于判断接龙是否匹配) +- 数据格式:JSON文件,类似quiz.json + +### 3. 拼音匹配逻辑 +- 需要拼音库支持(pypinyin) +- 支持谐音匹配(声母韵母匹配) +- 处理多音字情况 + +### 4. 裁判指令设计 +需要额外指令: +- `.idiom accept` - 接受上一个回答 +- `.idiom reject` - 拒绝上一个回答 +- 需要权限控制(谁可以当裁判?) + +### 5. 游戏流程设计 +``` +1. 开始游戏:.idiom start + - 机器人随机给出起始成语 + - 创建群级别游戏状态 + +2. 玩家接龙:.idiom [成语] + - 检查是否在词库中 + - 检查拼音是否匹配(首字拼音 == 上一个成语尾字拼音) + - 检查是否已使用过 + - 自动判断或等待裁判确认 + +3. 裁判操作:.idiom accept/reject + - 手动接受或拒绝最近的回答 + +4. 查看状态:.idiom status + - 显示当前成语、已用成语数量、参与人数 + +5. 结束游戏:.idiom stop + - 显示统计信息 + - 更新每个玩家的game_stats +``` + +## 现有代码分析 + +### CommandParser (utils/parser.py) +需要添加成语接龙指令映射: +```python +'.idiom': 'idiom', +'.成语接龙': 'idiom', +``` + +### callback.py (routers/callback.py) +需要在`handle_command`函数中添加idiom游戏分支: +```python +if game_type == 'idiom': + from games.idiom import IdiomGame + game = IdiomGame() + return await game.handle(command, chat_id, user_id) +``` + +### base.py (games/base.py) +需要更新`get_help_message()`和`get_stats_message()`,添加成语接龙信息。 + +### config.py +可能需要添加成语接龙相关配置: +```python +"idiom": { + "auto_judge": True, # 是否自动判断 + "require_approval": False, # 是否需要裁判确认 +} +``` + +# 提议的解决方案 + +(待INNOVATE模式填写) + +# 当前执行步骤:"1. 创建任务文件" + +# 任务进度 + +## [2025-10-28 15:45:00] +- 已修改:requirements.txt, config.py, utils/parser.py, routers/callback.py, games/base.py +- 已创建:games/idiom.py +- 更改: + 1. 在requirements.txt中添加pypinyin==0.51.0依赖 + 2. 在config.py的GAME_CONFIG中添加idiom配置(起始成语池、历史显示数量) + 3. 在utils/parser.py的COMMAND_MAP中添加.idiom、.成语接龙、.成语指令映射 + 4. 创建games/idiom.py实现完整的成语接龙游戏逻辑: + - IdiomGame类继承BaseGame + - 实现handle()主指令分发方法 + - 实现_start_game()开始游戏 + - 实现_make_chain()玩家接龙 + - 实现_set_next_user()指定下一位 + - 实现_reject_idiom()裁判拒绝 + - 实现_show_status()显示状态 + - 实现_show_blacklist()显示黑名单 + - 实现_stop_game()结束游戏 + - 实现_get_pinyin()获取拼音(支持多音字) + - 实现_check_pinyin_match()检查拼音匹配(忽略声调) + - 实现_parse_mentioned_user()解析@用户 + - 实现_can_answer()权限检查(防连续、指定轮次) + - 实现_validate_idiom()词语验证(4字、拼音匹配、未使用、未黑名单) + - 实现get_help()帮助信息 + 5. 在routers/callback.py的handle_command()中添加idiom游戏分支 + 6. 在games/base.py的get_help_message()中添加成语接龙帮助信息 + 7. 在games/base.py的get_stats_message()的game_names字典中添加idiom映射 +- 原因:实现成语接龙游戏功能的所有核心代码 +- 阻碍因素:无 +- 状态:未确认 + +## [2025-10-28 15:50:00] +- 已修改:games/base.py +- 更改:在get_help_message()的成语接龙部分添加黑名单相关指令说明 + - 添加 `.idiom reject [词语]` - 拒绝词语加入黑名单(仅发起人) + - 添加 `.idiom blacklist` - 查看黑名单 +- 原因:用户反馈.help帮助信息中看不到黑名单机制的使用说明 +- 阻碍因素:无 +- 状态:未确认 + +## [2025-10-28 15:55:00] +- 已修改:games/idiom.py +- 已创建:data/idiom_blacklist.json +- 更改:将黑名单机制从游戏状态改为全局永久存储 + 1. 创建data/idiom_blacklist.json作为全局黑名单数据文件 + 2. 在IdiomGame.__init__()中添加黑名单文件路径和懒加载变量 + 3. 添加_load_blacklist()方法从文件懒加载全局黑名单 + 4. 添加_save_blacklist()方法保存黑名单到文件 + 5. 修改_validate_idiom()方法检查全局黑名单而非游戏状态中的黑名单 + 6. 修改_start_game()方法移除state_data中的blacklist字段初始化 + 7. 修改_reject_idiom()方法将词语添加到全局黑名单并保存到文件 + 8. 修改_show_blacklist()方法显示全局黑名单,不再依赖游戏状态 + 9. 更新所有提示信息,明确说明是"永久禁用" +- 原因:用户要求被拒绝的词语应该永久不可用,而不是仅本局游戏不可用 +- 阻碍因素:无 +- 状态:未确认 + +# 最终审查 + +(待REVIEW模式完成后填写) + diff --git a/config.py b/config.py index 43a0839..43d6e5e 100644 --- a/config.py +++ b/config.py @@ -58,5 +58,15 @@ GAME_CONFIG = { "quiz": { "timeout": 60, # 答题超时时间(秒) }, + "idiom": { + "max_history_display": 10, # 状态显示最近N个成语 + "starter_idioms": [ # 起始成语池 + "一马当先", "龙马精神", "马到成功", "开门见山", + "心想事成", "万事如意", "风调雨顺", "国泰民安", + "四季平安", "安居乐业", "业精于勤", "勤学苦练", + "练达老成", "成竹在胸", "胸有成竹", "竹报平安", + "平步青云", "云程发轫", "刃迎缕解", "解甲归田" + ] + }, } diff --git a/data/idiom_blacklist.json b/data/idiom_blacklist.json new file mode 100644 index 0000000..6faa731 --- /dev/null +++ b/data/idiom_blacklist.json @@ -0,0 +1,5 @@ +{ + "blacklist": [], + "description": "成语接龙游戏全局黑名单,被拒绝的词语将永久不可使用" +} + diff --git a/games/base.py b/games/base.py index 4058cb6..89786f3 100644 --- a/games/base.py +++ b/games/base.py @@ -64,6 +64,15 @@ def get_help_message() -> str: - `.quiz` - 随机问题 - `.quiz 答案` - 回答问题 +### 🀄 成语接龙 +- `.idiom start [成语]` - 开始游戏 +- `.idiom [成语]` - 接龙 +- `.idiom [成语] @某人` - 接龙并指定下一位 +- `.idiom stop` - 结束游戏 +- `.idiom status` - 查看状态 +- `.idiom reject [词语]` - 拒绝词语加入黑名单(仅发起人) +- `.idiom blacklist` - 查看黑名单 + ### 其他 - `.help` - 显示帮助 - `.stats` - 查看个人统计 @@ -95,7 +104,8 @@ def get_stats_message(user_id: int) -> str: game_names = { 'rps': '✊ 石头剪刀布', 'guess': '🔢 猜数字', - 'quiz': '📝 问答游戏' + 'quiz': '📝 问答游戏', + 'idiom': '🀄 成语接龙' } for row in stats: diff --git a/games/idiom.py b/games/idiom.py new file mode 100644 index 0000000..15a0384 --- /dev/null +++ b/games/idiom.py @@ -0,0 +1,664 @@ +"""成语接龙游戏""" +import json +import random +import logging +import time +import re +from pathlib import Path +from typing import Optional, Tuple, Dict, Any, List +from pypinyin import pinyin, Style +from games.base import BaseGame +from utils.parser import CommandParser +from config import GAME_CONFIG + +logger = logging.getLogger(__name__) + + +class IdiomGame(BaseGame): + """成语接龙游戏""" + + def __init__(self): + """初始化游戏""" + super().__init__() + self.config = GAME_CONFIG.get('idiom', {}) + self.max_history_display = self.config.get('max_history_display', 10) + self.starter_idioms = self.config.get('starter_idioms', [ + "一马当先", "龙马精神", "马到成功", "开门见山" + ]) + self._blacklist = None + self.blacklist_file = Path(__file__).parent.parent / "data" / "idiom_blacklist.json" + + async def handle(self, command: str, chat_id: int, user_id: int) -> str: + """处理成语接龙指令 + + Args: + command: 指令,如 ".idiom start" 或 ".idiom 马到成功" + chat_id: 会话ID + user_id: 用户ID + + Returns: + 回复消息 + """ + try: + # 提取参数 + _, args = CommandParser.extract_command_args(command) + args = args.strip() + + # 没有参数,显示帮助 + if not args: + return self.get_help() + + # 解析参数 + parts = args.split(maxsplit=1) + action = parts[0].lower() + + # 开始游戏 + if action in ['start', '开始']: + starter = parts[1].strip() if len(parts) > 1 else None + return self._start_game(chat_id, user_id, starter) + + # 结束游戏 + if action in ['stop', '结束', 'end']: + return self._stop_game(chat_id, user_id) + + # 查看状态 + if action in ['status', '状态']: + return self._show_status(chat_id) + + # 查看黑名单 + if action in ['blacklist', '黑名单']: + return self._show_blacklist(chat_id) + + # 查看帮助 + if action in ['help', '帮助']: + return self.get_help() + + # 裁判拒绝 + if action in ['reject', '拒绝']: + if len(parts) < 2: + return "❌ 请指定要拒绝的词语,如:`.idiom reject 词语`" + idiom_to_reject = parts[1].strip() + return self._reject_idiom(chat_id, user_id, idiom_to_reject) + + # 指定下一位 + if action in ['next', '下一位']: + if len(parts) < 2: + return "❌ 请@要指定的用户" + mentioned = self._parse_mentioned_user(args) + if not mentioned: + return "❌ 未识别到@的用户" + return self._set_next_user(chat_id, user_id, mentioned) + + # 默认:接龙 + # 整个args都是成语(可能包含@用户) + return self._make_chain(chat_id, user_id, args) + + except Exception as e: + logger.error(f"处理成语接龙指令错误: {e}", exc_info=True) + return f"❌ 处理指令出错: {str(e)}" + + def _load_blacklist(self) -> List[str]: + """懒加载全局黑名单 + + Returns: + 黑名单列表 + """ + if self._blacklist is None: + try: + if self.blacklist_file.exists(): + with open(self.blacklist_file, 'r', encoding='utf-8') as f: + data = json.load(f) + self._blacklist = data.get('blacklist', []) + else: + self._blacklist = [] + logger.info(f"黑名单加载完成,共 {len(self._blacklist)} 个词语") + except Exception as e: + logger.error(f"加载黑名单失败: {e}") + self._blacklist = [] + return self._blacklist + + def _save_blacklist(self): + """保存黑名单到文件""" + try: + self.blacklist_file.parent.mkdir(parents=True, exist_ok=True) + data = { + "blacklist": self._blacklist if self._blacklist is not None else [], + "description": "成语接龙游戏全局黑名单,被拒绝的词语将永久不可使用" + } + with open(self.blacklist_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + logger.info(f"黑名单已保存,共 {len(data['blacklist'])} 个词语") + except Exception as e: + logger.error(f"保存黑名单失败: {e}") + + def _get_pinyin(self, char: str, all_readings: bool = True) -> list: + """获取单字拼音 + + Args: + char: 单个汉字 + all_readings: 是否返回多音字的所有读音 + + Returns: + 拼音列表 + """ + try: + result = pinyin(char, style=Style.NORMAL, heteronym=all_readings) + return result[0] if result else [] + except Exception as e: + logger.error(f"获取拼音错误: {e}") + return [] + + def _check_pinyin_match(self, last_char: str, first_char: str) -> Tuple[bool, str, str]: + """检查拼音匹配 + + Args: + last_char: 上一个成语的最后一个字 + first_char: 当前成语的第一个字 + + Returns: + (是否匹配, 上一个拼音, 当前拼音) + """ + last_pinyins = self._get_pinyin(last_char) + first_pinyins = self._get_pinyin(first_char) + + # 只要有任何一个读音匹配就算成功 + for lp in last_pinyins: + for fp in first_pinyins: + if lp.lower() == fp.lower(): + return True, lp, fp + + # 没有匹配,返回第一个读音 + return False, last_pinyins[0] if last_pinyins else '', first_pinyins[0] if first_pinyins else '' + + def _parse_mentioned_user(self, content: str) -> Optional[int]: + """解析消息中@的用户ID + + Args: + content: 消息内容 + + Returns: + 用户ID或None + """ + # 简化实现:查找@后的数字 + # 实际WPS API可能有特定格式,需要根据文档调整 + match = re.search(r'@.*?(\d+)', content) + if match: + try: + return int(match.group(1)) + except ValueError: + pass + return None + + def _can_answer(self, state_data: Dict, user_id: int) -> Tuple[bool, str]: + """检查用户是否可以接龙 + + Args: + state_data: 游戏状态数据 + user_id: 用户ID + + Returns: + (是否可以, 错误消息) + """ + # 不能是上一个接龙的人 + if state_data.get('last_user_id') == user_id: + return False, "❌ 不能连续接龙哦!让其他人来吧" + + # 如果指定了下一位,必须是指定的人 + if state_data.get('next_user_id') is not None: + if state_data['next_user_id'] != user_id: + return False, f"❌ 现在轮到指定的人接龙了" + + return True, "" + + def _validate_idiom(self, idiom: str, state_data: Dict) -> Tuple[bool, str]: + """验证词语有效性 + + Args: + idiom: 待验证的词语 + state_data: 游戏状态数据 + + Returns: + (是否有效, 错误消息) + """ + # 检查长度 + if len(idiom) != 4: + return False, "❌ 词语必须是4个字" + + # 检查是否已使用 + if idiom in state_data.get('used_idioms', []): + return False, f"❌ 「{idiom}」已经用过了" + + # 检查是否在全局黑名单 + blacklist = self._load_blacklist() + if idiom in blacklist: + return False, f"❌ 「{idiom}」在黑名单中(永久禁用)" + + # 检查拼音匹配 + current_idiom = state_data.get('current_idiom', '') + if current_idiom: + last_char = current_idiom[-1] + first_char = idiom[0] + is_match, last_py, first_py = self._check_pinyin_match(last_char, first_char) + + if not is_match: + return False, f"❌ 首字「{first_char}」拼音[{first_py}]不匹配上个成语尾字「{last_char}」拼音[{last_py}]" + + return True, "" + + def _start_game(self, chat_id: int, user_id: int, starter_idiom: Optional[str]) -> str: + """开始新游戏 + + Args: + chat_id: 会话ID + user_id: 用户ID + starter_idiom: 起始成语(可选) + + Returns: + 提示消息 + """ + # 检查是否已有进行中的游戏(user_id=0表示群级别状态) + state = self.db.get_game_state(chat_id, 0, 'idiom') + if state: + return "⚠️ 已经有一个进行中的游戏了!\n\n输入 `.idiom stop` 结束当前游戏" + + # 确定起始成语 + if starter_idiom: + # 验证起始成语 + if len(starter_idiom) != 4: + return "❌ 起始成语必须是4个字" + idiom = starter_idiom + else: + # 随机选择 + idiom = random.choice(self.starter_idioms) + + # 获取最后一个字的拼音 + last_char = idiom[-1] + last_pinyin_list = self._get_pinyin(last_char) + last_pinyin = last_pinyin_list[0] if last_pinyin_list else '' + + # 创建游戏状态 + state_data = { + 'creator_id': user_id, + 'current_idiom': idiom, + 'current_pinyin_last': last_pinyin, + 'last_user_id': user_id, # 发起人可以接第一个 + 'next_user_id': None, + 'used_idioms': [idiom], + 'chain_length': 1, + 'participants': {}, + 'history': [ + { + 'user_id': user_id, + 'idiom': idiom, + 'timestamp': int(time.time()) + } + ], + 'status': 'playing' + } + + # 保存群状态(user_id=0) + self.db.save_game_state(chat_id, 0, 'idiom', state_data) + + text = f"## 🀄 成语接龙开始!\n\n" + text += f"**起始成语**:{idiom} [{last_pinyin}]\n\n" + text += f"任何人都可以接龙,输入 `.idiom [成语]` 开始吧!\n\n" + text += f"💡 提示:可以用 `.idiom [成语] @某人` 指定下一位" + + return text + + def _make_chain(self, chat_id: int, user_id: int, args: str) -> str: + """玩家接龙 + + Args: + chat_id: 会话ID + user_id: 用户ID + args: 参数(成语 + 可能的@用户) + + Returns: + 结果消息 + """ + # 获取群状态 + state = self.db.get_game_state(chat_id, 0, 'idiom') + if not state: + return "⚠️ 还没有开始游戏呢!\n\n输入 `.idiom start` 开始游戏" + + state_data = state['state_data'] + + # 检查用户权限 + can_answer, error_msg = self._can_answer(state_data, user_id) + if not can_answer: + return error_msg + + # 解析成语和@用户 + # 提取成语(去除@部分) + idiom_match = re.match(r'^([^\s@]+)', args) + if not idiom_match: + return "❌ 请输入4个字的词语" + + idiom = idiom_match.group(1).strip() + + # 验证词语 + is_valid, error_msg = self._validate_idiom(idiom, state_data) + if not is_valid: + return error_msg + + # 解析@用户 + mentioned_user_id = self._parse_mentioned_user(args) + + # 获取拼音 + last_char = idiom[-1] + last_pinyin_list = self._get_pinyin(last_char) + last_pinyin = last_pinyin_list[0] if last_pinyin_list else '' + + # 更新状态 + state_data['current_idiom'] = idiom + state_data['current_pinyin_last'] = last_pinyin + state_data['last_user_id'] = user_id + state_data['next_user_id'] = mentioned_user_id + state_data['used_idioms'].append(idiom) + state_data['chain_length'] += 1 + + # 更新参与者统计 + if str(user_id) not in state_data['participants']: + state_data['participants'][str(user_id)] = 0 + state_data['participants'][str(user_id)] += 1 + + # 记录历史 + state_data['history'].append({ + 'user_id': user_id, + 'idiom': idiom, + 'timestamp': int(time.time()) + }) + + # 保存状态 + self.db.save_game_state(chat_id, 0, 'idiom', state_data) + + # 构建回复 + text = f"## ✅ 接龙成功!\n\n" + text += f"**{idiom}** [{last_pinyin}]\n\n" + text += f"**当前链长**:{state_data['chain_length']}\n\n" + + user_count = state_data['participants'][str(user_id)] + text += f"@用户{user_id} 成功次数:{user_count}\n\n" + + if mentioned_user_id: + text += f"已指定 @用户{mentioned_user_id} 接龙\n\n" + else: + text += "任何人都可以接龙\n\n" + + text += "继续加油!💪" + + return text + + def _set_next_user(self, chat_id: int, user_id: int, next_user_id: int) -> str: + """指定下一位接龙者 + + Args: + chat_id: 会话ID + user_id: 当前用户ID + next_user_id: 指定的下一位用户ID + + Returns: + 提示消息 + """ + # 获取群状态 + state = self.db.get_game_state(chat_id, 0, 'idiom') + if not state: + return "⚠️ 还没有开始游戏呢!" + + state_data = state['state_data'] + + # 检查是否是最后接龙的人 + if state_data.get('last_user_id') != user_id: + return "❌ 只有最后接龙成功的人可以指定下一位" + + # 更新状态 + state_data['next_user_id'] = next_user_id + self.db.save_game_state(chat_id, 0, 'idiom', state_data) + + return f"✅ 已指定 @用户{next_user_id} 接龙" + + def _reject_idiom(self, chat_id: int, user_id: int, idiom: str) -> str: + """裁判拒绝词语 + + Args: + chat_id: 会话ID + user_id: 用户ID + idiom: 要拒绝的词语 + + Returns: + 提示消息 + """ + # 获取群状态 + state = self.db.get_game_state(chat_id, 0, 'idiom') + if not state: + return "⚠️ 还没有开始游戏呢!" + + state_data = state['state_data'] + + # 检查权限(仅发起人) + if state_data.get('creator_id') != user_id: + return "❌ 只有游戏发起人可以执行裁判操作" + + # 添加到全局黑名单 + blacklist = self._load_blacklist() + if idiom not in blacklist: + blacklist.append(idiom) + self._save_blacklist() + logger.info(f"词语「{idiom}」已加入全局黑名单") + + # 如果是最后一个成语,回退状态 + if state_data.get('current_idiom') == idiom and len(state_data['history']) > 1: + # 移除最后一条历史 + removed = state_data['history'].pop() + removed_user = str(removed['user_id']) + + # 减少该用户的计数 + if removed_user in state_data['participants']: + state_data['participants'][removed_user] -= 1 + if state_data['participants'][removed_user] <= 0: + del state_data['participants'][removed_user] + + # 恢复到上一个成语 + if state_data['history']: + last_entry = state_data['history'][-1] + last_idiom = last_entry['idiom'] + last_char = last_idiom[-1] + last_pinyin_list = self._get_pinyin(last_char) + + state_data['current_idiom'] = last_idiom + state_data['current_pinyin_last'] = last_pinyin_list[0] if last_pinyin_list else '' + state_data['last_user_id'] = last_entry['user_id'] + state_data['next_user_id'] = None + state_data['chain_length'] -= 1 + + # 从已使用列表中移除 + if idiom in state_data['used_idioms']: + state_data['used_idioms'].remove(idiom) + + # 保存状态 + self.db.save_game_state(chat_id, 0, 'idiom', state_data) + + text = f"✅ 已将「{idiom}」加入全局黑名单(永久禁用)" + if state_data.get('current_idiom') != idiom: + text += f"\n\n当前成语:{state_data['current_idiom']}" + else: + text += "\n\n游戏状态已回退" + + return text + + def _show_status(self, chat_id: int) -> str: + """显示游戏状态 + + Args: + chat_id: 会话ID + + Returns: + 状态信息 + """ + # 获取群状态 + state = self.db.get_game_state(chat_id, 0, 'idiom') + if not state: + return "⚠️ 还没有开始游戏呢!\n\n输入 `.idiom start` 开始游戏" + + state_data = state['state_data'] + + text = f"## 🀄 成语接龙状态\n\n" + text += f"**当前成语**:{state_data['current_idiom']} [{state_data['current_pinyin_last']}]\n\n" + text += f"**链长**:{state_data['chain_length']}\n\n" + + # 下一位 + if state_data.get('next_user_id'): + text += f"**下一位**:@用户{state_data['next_user_id']}\n\n" + else: + text += f"**下一位**:任何人都可以接龙\n\n" + + # 参与者排行 + if state_data['participants']: + text += f"### 🏆 参与者排行\n\n" + sorted_participants = sorted( + state_data['participants'].items(), + key=lambda x: x[1], + reverse=True + ) + for idx, (uid, count) in enumerate(sorted_participants[:5], 1): + text += f"{idx}. @用户{uid} - {count}次\n" + text += "\n" + + # 最近成语 + history = state_data.get('history', []) + if history: + display_count = min(self.max_history_display, len(history)) + recent = history[-display_count:] + text += f"### 📜 最近{display_count}个成语\n\n" + text += " → ".join([h['idiom'] for h in recent]) + + return text + + def _show_blacklist(self, chat_id: int) -> str: + """显示全局黑名单 + + Args: + chat_id: 会话ID(保留参数以保持接口一致性) + + Returns: + 黑名单信息 + """ + # 加载全局黑名单 + blacklist = self._load_blacklist() + + if not blacklist: + return "📋 全局黑名单为空\n\n💡 发起人可使用 `.idiom reject [词语]` 添加不合适的词语到黑名单" + + text = f"## 📋 全局黑名单词语(永久禁用)\n\n" + text += f"**共 {len(blacklist)} 个词语**\n\n" + text += "、".join(blacklist) + text += "\n\n💡 这些词语在所有游戏中都不可使用" + + return text + + def _stop_game(self, chat_id: int, user_id: int) -> str: + """结束游戏 + + Args: + chat_id: 会话ID + user_id: 用户ID + + Returns: + 总结消息 + """ + # 获取群状态 + state = self.db.get_game_state(chat_id, 0, 'idiom') + if not state: + return "⚠️ 还没有开始游戏呢!" + + state_data = state['state_data'] + + # 构建总结 + text = f"## 🎮 游戏结束!\n\n" + text += f"**总链长**:{state_data['chain_length']}\n\n" + text += f"**参与人数**:{len(state_data['participants'])}\n\n" + + # 排行榜 + if state_data['participants']: + text += f"### 🏆 排行榜\n\n" + sorted_participants = sorted( + state_data['participants'].items(), + key=lambda x: x[1], + reverse=True + ) + for idx, (uid, count) in enumerate(sorted_participants, 1): + text += f"{idx}. @用户{uid} - {count}次\n" + # 更新统计 + try: + for _ in range(count): + self.db.update_game_stats(int(uid), 'idiom', win=True) + except Exception as e: + logger.error(f"更新统计失败: {e}") + text += "\n" + + # 完整接龙 + history = state_data.get('history', []) + if history: + text += f"### 📜 完整接龙\n\n" + idioms = [h['idiom'] for h in history] + text += " → ".join(idioms) + + # 删除游戏状态 + self.db.delete_game_state(chat_id, 0, 'idiom') + + return text + + def _format_history(self, history: list, count: int) -> str: + """格式化历史记录 + + Args: + history: 历史记录列表 + count: 显示数量 + + Returns: + 格式化的字符串 + """ + if not history: + return "" + + display_count = min(count, len(history)) + recent = history[-display_count:] + return " → ".join([h['idiom'] for h in recent]) + + def get_help(self) -> str: + """获取帮助信息""" + return """## 🀄 成语接龙 + +### 基础用法 +- `.idiom start [成语]` - 开始游戏(可指定起始成语) +- `.idiom [成语]` - 接龙 +- `.idiom [成语] @某人` - 接龙并指定下一位 +- `.idiom stop` - 结束游戏(任何人可执行) + +### 其他指令 +- `.idiom status` - 查看游戏状态 +- `.idiom blacklist` - 查看黑名单 +- `.idiom reject [词语]` - 裁判拒绝词语(仅发起人) +- `.idiom next @某人` - 指定下一位(仅最后接龙者) + +### 游戏规则 +- 词语必须是4个字 +- 首字拼音必须匹配上个成语尾字拼音(忽略声调) +- 不能重复使用成语 +- 不能连续接龙 +- 黑名单词语不可使用 +- 任何人都可以结束游戏 + +### 示例 +``` +.idiom start 一马当先 # 开始游戏 +.idiom 先声夺人 # 接龙 +.idiom 人山人海 @张三 # 接龙并指定下一位 +.idiom reject 某词 # 发起人拒绝某词 +.idiom stop # 结束游戏 +``` + +💡 提示:支持多音字和谐音接龙 +""" + diff --git a/requirements.txt b/requirements.txt index f0cbb24..97b3b00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,5 +15,8 @@ pydantic-settings==2.1.0 # 系统监控 psutil==7.1.2 +# 拼音处理 +pypinyin==0.51.0 + # 注意:使用Python标准库sqlite3,不引入SQLAlchemy diff --git a/routers/callback.py b/routers/callback.py index 3d59e56..c56edb6 100644 --- a/routers/callback.py +++ b/routers/callback.py @@ -146,6 +146,12 @@ async def handle_command(game_type: str, command: str, game = QuizGame() return await game.handle(command, chat_id, user_id) + # 成语接龙 + if game_type == 'idiom': + from games.idiom import IdiomGame + game = IdiomGame() + return await game.handle(command, chat_id, user_id) + # 未知游戏类型 logger.warning(f"未知游戏类型: {game_type}") return "❌ 未知的游戏类型" diff --git a/utils/parser.py b/utils/parser.py index 4170cad..d6f8b7a 100644 --- a/utils/parser.py +++ b/utils/parser.py @@ -30,6 +30,11 @@ class CommandParser: '.quiz': 'quiz', '.问答': 'quiz', + # 成语接龙 + '.idiom': 'idiom', + '.成语接龙': 'idiom', + '.成语': 'idiom', + # 帮助 '.help': 'help', '.帮助': 'help',