""" Net-Games 主服务器 Flask-SocketIO 游戏服务 + 登录认证 """ import os import sys import logging import json from datetime import datetime, timedelta from functools import wraps from typing import Dict, Any, Optional from flask import Flask, request, jsonify, send_from_directory from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect, disconnect from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager, login_user, logout_user, login_required, current_user from flask_cors import CORS from werkzeug.security import generate_password_hash, check_password_hash import threading import asyncio # 添加当前目录到路径 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from config import ( HOST, PORT, DEBUG, SOCKET_IO_CONFIG, LOG_LEVEL, LOG_FORMAT ) from engine import RoomManager, HumanPlayer, AIPlayer # 配置日志 logging.basicConfig(level=getattr(logging, LOG_LEVEL), format=LOG_FORMAT) logger = logging.getLogger(__name__) # 创建 Flask 应用 app = Flask(__name__) app.config['SECRET_KEY'] = 'net-games-secret-key-2026' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///net_games_auth.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # Session cookie 配置 app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['SESSION_COOKIE_SECURE'] = False # 初始化扩展 db = SQLAlchemy(app) CORS(app, supports_credentials=True, origins='*') # Flask-Login login_manager = LoginManager() login_manager.init_app(app) # 创建 SocketIO socketio = SocketIO(app, **SOCKET_IO_CONFIG) # 获取房间管理器 room_manager = RoomManager() # 存储 socket 到玩家的映射 socket_player_map: dict = {} # 存储用户到 socket 的映射(用于单点登录,一个用户只能有一个活跃连接) user_socket_map: dict = {} # 存储用户到房间的映射(用于服务器端重连,支持跨浏览器) user_room_map: dict = {} # user_id/username -> room_id # 回合超时管理 room_turn_timers: dict = {} # room_id -> timer player_timeout_count: dict = {} # player_id -> count TURN_TIMEOUT_SECONDS = 30 # 回合超时时间 MAX_TIMEOUT_COUNT = 3 # 最大超时次数,超过则托管 # 注册游戏类型 from engine.test_game import TestGame from doudizhu import DoudizhuGame room_manager.register_game('test_game', TestGame) room_manager.register_game('doudizhu', DoudizhuGame) # 导入AI适配器 from doudizhu.ai_adapters import create_doudizhu_ai, DoudizhuRuleAI # ============ 数据库模型 ============ class User(db.Model): """用户模型""" __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) nickname = db.Column(db.String(80), nullable=True) # 昵称,默认同用户名 password_hash = db.Column(db.String(200), nullable=False) created_at = db.Column(db.DateTime, default=db.func.current_timestamp()) # 用户角色:admin 或 user role = db.Column(db.String(20), default='user') # 各游戏独立积分 game_scores = db.Column(db.Text, default='{}') def is_admin(self): """检查是否为管理员""" return self.role == 'admin' def get_nickname(self): """获取昵称,默认为用户名""" return self.nickname or self.username def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) def get_game_score(self, game_id): try: scores = json.loads(self.game_scores or '{}') return scores.get(game_id, 0) except: return 0 def add_game_score(self, game_id, points): try: scores = json.loads(self.game_scores or '{}') scores[game_id] = scores.get(game_id, 0) + points self.game_scores = json.dumps(scores) return scores[game_id] except: return 0 def to_dict(self, include_role=False): data = { 'id': self.id, 'username': self.username, 'nickname': self.get_nickname(), 'created_at': self.created_at.isoformat() if self.created_at else None } if include_role: data['role'] = self.role data['is_admin'] = self.is_admin() return data # Flask-Login 要求的方法 def is_authenticated(self): return True def is_active(self): return True def is_anonymous(self): return False def get_id(self): return str(self.id) @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) def init_default_users(): """初始化默认用户""" default_users = [ {'username': 'admin', 'password': 'zf9wx4xm7', 'nickname': '管理员', 'role': 'admin'} ] for user_data in default_users: existing = User.query.filter_by(username=user_data['username']).first() if not existing: user = User( username=user_data['username'], nickname=user_data.get('nickname', user_data['username']), role=user_data.get('role', 'user') ) user.set_password(user_data['password']) db.session.add(user) logger.info(f"✅ 创建默认用户: {user_data['username']} (role={user_data.get('role', 'user')})") else: # 更新现有用户的角色(如果未设置) if not existing.role: existing.role = user_data.get('role', 'user') logger.info(f"🔄 更新用户角色: {user_data['username']} -> {existing.role}") db.session.commit() def api_login_required(f): """API 登录检查""" @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated: return jsonify({ 'error': 'Unauthorized', 'message': '请先登录', 'code': 401 }), 401 return f(*args, **kwargs) return decorated_function def admin_required(f): """管理员权限检查""" @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated: return jsonify({ 'error': 'Unauthorized', 'message': '请先登录', 'code': 401 }), 401 if not current_user.is_admin(): return jsonify({ 'error': 'Forbidden', 'message': '需要管理员权限', 'code': 403 }), 403 return f(*args, **kwargs) return decorated_function # ============ HTTP API ============ # 静态文件目录 CLIENT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'client') HUB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'hub') @app.route('/') def index(): """首页 - 返回游戏大厅""" return send_from_directory(HUB_DIR, 'index.html') @app.route('/doudizhu/') def doudizhu_game(): """斗地主游戏""" return send_from_directory(CLIENT_DIR, 'index.html') # ============ 认证 API ============ @app.route('/api/auth/status') def auth_status(): """检查登录状态""" if current_user.is_authenticated: return jsonify({ 'authenticated': True, 'user': current_user.to_dict(include_role=True) }) return jsonify({'authenticated': False, 'user': None}) @app.route('/api/auth/login', methods=['POST']) def login(): """用户登录""" data = request.get_json() if not data: return jsonify({'error': '请求数据为空'}), 400 username = data.get('username', '').strip() password = data.get('password', '').strip() if not username or not password: return jsonify({'error': '用户名和密码不能为空'}), 400 user = User.query.filter_by(username=username).first() if not user or not user.check_password(password): return jsonify({'error': '用户名或密码错误'}), 401 login_user(user, remember=True, duration=timedelta(days=30)) return jsonify({ 'success': True, 'message': '登录成功', 'user': user.to_dict(include_role=True) }) @app.route('/api/auth/register', methods=['POST']) def register(): """用户注册""" data = request.get_json() if not data: return jsonify({'error': '请求数据为空'}), 400 username = data.get('username', '').strip() password = data.get('password', '').strip() if not username or not password: return jsonify({'error': '用户名和密码不能为空'}), 400 if len(username) < 3 or len(username) > 20: return jsonify({'error': '用户名长度需在3-20个字符之间'}), 400 if len(password) < 6: return jsonify({'error': '密码长度至少6位'}), 400 # 检查用户名是否已存在 if User.query.filter_by(username=username).first(): return jsonify({'error': '用户名已被注册'}), 409 # 创建新用户(昵称默认为用户名) new_user = User(username=username, nickname=username) new_user.set_password(password) db.session.add(new_user) db.session.commit() # 自动登录 login_user(new_user, remember=True, duration=timedelta(days=30)) return jsonify({ 'success': True, 'message': '注册成功', 'user': new_user.to_dict() }) @app.route('/api/auth/logout', methods=['POST']) @login_required def logout(): """用户登出""" logout_user() return jsonify({ 'success': True, 'message': '已登出' }) @app.route('/api/user/profile', methods=['GET']) @api_login_required def get_profile(): """获取用户个人资料""" return jsonify({ 'success': True, 'user': current_user.to_dict() }) @app.route('/api/user/profile', methods=['PUT']) @api_login_required def update_profile(): """更新用户个人资料(昵称)""" data = request.get_json() if not data: return jsonify({'error': '请求数据为空'}), 400 nickname = data.get('nickname', '').strip() if nickname: if len(nickname) < 2 or len(nickname) > 20: return jsonify({'error': '昵称长度需在2-20个字符之间'}), 400 current_user.nickname = nickname db.session.commit() return jsonify({ 'success': True, 'message': '个人资料已更新', 'user': current_user.to_dict() }) @app.route('/api/user/password', methods=['PUT']) @api_login_required def update_password(): """修改密码""" data = request.get_json() if not data: return jsonify({'error': '请求数据为空'}), 400 old_password = data.get('old_password', '') new_password = data.get('new_password', '') if not old_password or not new_password: return jsonify({'error': '请填写旧密码和新密码'}), 400 if len(new_password) < 6: return jsonify({'error': '新密码长度至少6位'}), 400 # 验证旧密码 if not current_user.check_password(old_password): return jsonify({'error': '旧密码错误'}), 401 # 更新密码 current_user.set_password(new_password) db.session.commit() return jsonify({ 'success': True, 'message': '密码修改成功,请重新登录' }) @app.route('/api/user/score', methods=['POST']) @api_login_required def update_score(): """更新游戏积分""" data = request.get_json() if not data: return jsonify({'error': '请求数据为空'}), 400 game_id = data.get('game') points = data.get('points', 0) if not game_id: return jsonify({'error': '缺少游戏ID'}), 400 new_score = current_user.add_game_score(game_id, points) db.session.commit() return jsonify({ 'success': True, 'game': game_id, 'new_score': new_score }) @app.route('/doudizhu/') def doudizhu_static(filename): """斗地主游戏静态文件""" return send_from_directory(CLIENT_DIR, filename) @app.route('/') def static_files(filename): """静态文件服务""" return send_from_directory(CLIENT_DIR, filename) @app.route('/api/rooms') def get_rooms(): """获取房间列表""" game_type = request.args.get('game_type') status = request.args.get('status', 'waiting') rooms = room_manager.get_all_rooms(game_type=game_type, status=status) return jsonify({ 'success': True, 'rooms': [room.to_dict(include_game_state=False) for room in rooms] }) @app.route('/api/rooms/') def get_room_info(room_code: str): """获取房间信息""" room = room_manager.get_room(room_code=room_code) if not room: return jsonify({'success': False, 'error': '房间不存在'}), 404 return jsonify({ 'success': True, 'room': room.to_dict(include_game_state=True) }) @app.route('/api/stats') def get_stats(): """获取服务统计(公开接口)""" all_rooms = room_manager.get_all_rooms() return jsonify({ 'success': True, 'stats': { 'total_rooms': len(all_rooms), 'waiting_rooms': len([r for r in all_rooms if r.status == 'waiting']), 'playing_rooms': len([r for r in all_rooms if r.status == 'playing']), 'total_players': sum(r.player_count for r in all_rooms), } }) @app.route('/api/user/scores') @api_login_required def get_user_scores(): """获取当前用户各游戏积分""" game_id = request.args.get('game') if game_id: # 获取特定游戏积分 score = current_user.get_game_score(game_id) return jsonify({ 'success': True, 'game_id': game_id, 'score': score }) else: # 获取所有游戏积分 try: scores = json.loads(current_user.game_scores or '{}') except: scores = {} return jsonify({ 'success': True, 'scores': scores }) @app.route('/api/admin/stats') @admin_required def get_admin_stats(): """获取管理员详细统计""" all_rooms = room_manager.get_all_rooms() # 按游戏类型统计 game_stats = {} for room in all_rooms: game_type = room.game_type if game_type not in game_stats: game_stats[game_type] = { 'total': 0, 'waiting': 0, 'playing': 0, 'players': 0 } game_stats[game_type]['total'] += 1 game_stats[game_type]['players'] += room.player_count if room.status == 'waiting': game_stats[game_type]['waiting'] += 1 elif room.status == 'playing': game_stats[game_type]['playing'] += 1 # 房间详情 rooms_detail = [] for room in all_rooms: rooms_detail.append({ 'code': room.code, 'game_type': room.game_type, 'status': room.status, 'player_count': room.player_count, 'max_players': room.max_players, 'host_id': room.host_id, 'created_at': room.created_at, 'players': [ { 'id': p.id, 'name': p.name, 'type': 'AI' if p.is_ai else 'Human', 'is_connected': p.is_connected, 'is_ai_controlled': p.is_ai_controlled } for p in room.players.values() ] }) # 在线用户统计 online_users = len(active_usernames) return jsonify({ 'success': True, 'stats': { 'total_rooms': len(all_rooms), 'waiting_rooms': len([r for r in all_rooms if r.status == 'waiting']), 'playing_rooms': len([r for r in all_rooms if r.status == 'playing']), 'total_players': sum(r.player_count for r in all_rooms), 'online_users': online_users, 'by_game': game_stats, 'rooms': rooms_detail } }) @app.route('/api/admin/rooms/', methods=['DELETE']) @admin_required def admin_close_room(room_code: str): """管理员强制关闭房间""" room = room_manager.get_room(room_code=room_code) if not room: return jsonify({'error': '房间不存在'}), 404 # 通知房间内玩家 socketio.emit('room_closed', { 'message': '房间已被管理员关闭', 'by_admin': True }, room=room.id) # 关闭房间并清理 room.close() room_manager._cleanup_room(room.id) # 清理用户-房间映射 for username, mapped_room_id in list(user_room_map.items()): if mapped_room_id == room.id: del user_room_map[username] logger.info(f"管理员关闭了房间: {room_code}") return jsonify({ 'success': True, 'message': f'房间 {room_code} 已关闭' }) @app.route('/test_client.html') def serve_test_client(): """提供测试客户端""" return send_from_directory('.', 'test_client.html') # ============ Socket.IO Events ============ @socketio.on('connect') def handle_connect(): """客户端连接""" socket_id = request.sid logger.info(f"Client connected: {socket_id}") # 检查是否已登录用户 if current_user.is_authenticated: user_id = current_user.id # 检查该用户是否已有活跃连接 old_socket_id = user_socket_map.get(user_id) if old_socket_id and old_socket_id != socket_id: logger.info(f"User {user_id} 新连接 {socket_id},踢掉旧连接 {old_socket_id}") # 清理旧连接的游戏状态 old_player_id = socket_player_map.get(old_socket_id) if old_player_id: old_room = room_manager.get_room_by_player(old_player_id) if old_room: old_player = old_room.players.get(old_player_id) if old_player: # 标记旧玩家为断开状态,允许重连匹配 old_player.disconnect() logger.info(f"Player {old_player.name} marked as disconnected (kicked)") # 从 active_usernames 中移除旧连接 old_identity = old_player.name if old_identity in active_usernames and active_usernames[old_identity] == old_player_id: del active_usernames[old_identity] logger.info(f"Removed {old_identity} from active_usernames (kicked)") # 通知旧连接被踢下线 socketio.emit('kicked', { 'reason': 'new_login', 'message': '您的账号已在其他页面登录' }, room=old_socket_id) # 断开旧连接 socketio.server.disconnect(old_socket_id, namespace='/') # 清理旧连接的映射 if old_socket_id in socket_player_map: del socket_player_map[old_socket_id] # 记录新连接 user_socket_map[user_id] = socket_id logger.info(f"User {user_id} 绑定到 socket {socket_id}") # 检查用户是否有正在进行的游戏 if current_user.is_authenticated: username = current_user.username if username in user_room_map: room_id = user_room_map[username] room = room_manager.get_room(room_id=room_id) if room: logger.info(f"User {username} has active game in room {room.code}") # 发送连接响应,包含用户当前游戏状态 response = { 'socket_id': socket_id, 'message': '欢迎连接到 Net-Games' } # 如果是登录用户且有正在进行的游戏,告诉客户端 if current_user.is_authenticated: username = current_user.username if username in user_room_map: room_id = user_room_map[username] room = room_manager.get_room(room_id=room_id) if room: response['has_active_game'] = True response['room_code'] = room.code response['room_id'] = room.id logger.info(f"Notifying user {username} of active game in room {room.code}") emit('connected', response) @socketio.on('disconnect') def handle_disconnect(): """客户端断开""" socket_id = request.sid logger.info(f"Client disconnected: {socket_id}") # 清理用户-socket映射(如果是当前活跃连接) if current_user.is_authenticated: user_id = current_user.id if user_socket_map.get(user_id) == socket_id: del user_socket_map[user_id] logger.info(f"User {user_id} 断开连接,清理映射") # 如果玩家在某个房间,处理离线(但不移除玩家,支持重连) player_id = socket_player_map.get(socket_id) if player_id: room = room_manager.get_room_by_player(player_id) if room: player = room.players.get(player_id) if player: player.disconnect() logger.info(f"Player {player.name} disconnected from room {room.code}, waiting for reconnect") # 从 active_usernames 中移除,允许用相同身份重新加入 identity_key = current_user.username if current_user.is_authenticated else player.name if identity_key in active_usernames and active_usernames[identity_key] == player_id: del active_usernames[identity_key] logger.info(f"Removed {identity_key} from active_usernames") # 通知房间其他玩家 emit('player_disconnected', { 'player_id': player_id, 'player_name': player.name, 'will_ai_control': True # 提示将启用AI托管 }, room=room.id, broadcast=True) # 启动定时器:30秒后启用AI托管 socketio.start_background_task( ai_control_after_timeout, room.id, player_id, timeout=30 ) # 清理映射(但保留玩家-房间关系) del socket_player_map[socket_id] @socketio.on('create_room') def handle_create_room(data: dict): """创建房间""" game_type = data.get('game_type', 'doudizhu') player_name = data.get('player_name', '玩家') config = data.get('config', {}) ai_config = data.get('ai_config', {}) # {ai_count: 2, ai_types: ['rule', 'deepseek']} ai_players = data.get('ai_players', []) # [{name: 'AI-1', ai_type: 'rule'}, ...] # 检查游戏类型是否已注册 if game_type not in room_manager._game_registry: emit('error', { 'code': 'GAME_NOT_REGISTERED', 'message': f'游戏类型 {game_type} 未注册' }) return # 单点登录检查:使用用户名(已登录)或昵称(未登录) identity_key = current_user.username if current_user.is_authenticated else player_name if identity_key in active_usernames: existing_player_id = active_usernames[identity_key] existing_room = room_manager.get_room_by_player(existing_player_id) if existing_room: existing_player = existing_room.players.get(existing_player_id) if existing_player and existing_player.is_connected: identity_type = "账号" if current_user.is_authenticated else "昵称" emit('error', { 'code': 'ALREADY_ONLINE', 'message': f'{identity_type} "{identity_key}" 已在游戏中,请先退出或等待30秒后重试' }) return else: del active_usernames[identity_key] # 获取游戏配置 max_players = config.get('max_players', 3) # 创建房间 room = room_manager.create_room(game_type, max_players, config) if not room: emit('error', { 'code': 'CREATE_FAILED', 'message': '创建房间失败' }) return # 创建玩家 player = HumanPlayer(player_name) player.connect(request.sid) # 记录活跃玩家(使用用户名或昵称) active_usernames[identity_key] = player.id # 玩家加入房间 success, message = room_manager.join_room(room.code, player) if not success: emit('error', { 'code': 'JOIN_FAILED', 'message': message }) return # 记录用户-房间映射(支持服务器端重连) if current_user.is_authenticated: user_room_map[current_user.username] = room.id logger.info(f"User {current_user.username} mapped to room {room.code}") # 记录映射 socket_player_map[request.sid] = player.id # 加入 Socket.IO 房间 join_room(room.id) # 如果是斗地主,自动添加AI玩家 if game_type == 'doudizhu': # 优先使用 ai_players 参数(新格式) if ai_players: for ai_info in ai_players: ai_name = ai_info.get('name', f"AI-{len(room.players)+1}") ai_type = ai_info.get('ai_type', 'rule') ai_player = AIPlayer(ai_name, ai_type) ai_player.ai_type = ai_type room.add_player(ai_player) logger.info(f"AI player {ai_player.name} added to room {room.code}") else: # 兼容旧格式 ai_config ai_count = ai_config.get('ai_count', 2) ai_types = ai_config.get('ai_types', ['rule', 'rule']) for i in range(ai_count): ai_type = ai_types[i] if i < len(ai_types) else 'rule' ai_player = AIPlayer(f"AI-{i+1}({ai_type})", ai_type) ai_player.ai_type = ai_type # 保存AI类型 room.add_player(ai_player) logger.info(f"AI player {ai_player.name} added to room {room.code}") logger.info(f"Room {room.code} created by {player_name}") emit('room_created', { 'success': True, 'room': room.to_dict(), 'player': player.to_dict(include_private=True) }) @socketio.on('join_room') def handle_join_room(data: dict): """加入房间""" room_code = data.get('room_code', '').upper() player_name = data.get('player_name', '玩家') if not room_code: emit('error', { 'code': 'INVALID_CODE', 'message': '房间号不能为空' }) return # 单点登录检查:使用用户名(已登录)或昵称(未登录) identity_key = current_user.username if current_user.is_authenticated else player_name if identity_key in active_usernames: existing_player_id = active_usernames[identity_key] existing_room = room_manager.get_room_by_player(existing_player_id) if existing_room: existing_player = existing_room.players.get(existing_player_id) if existing_player and existing_player.is_connected: identity_type = "账号" if current_user.is_authenticated else "昵称" emit('error', { 'code': 'ALREADY_ONLINE', 'message': f'{identity_type} "{identity_key}" 已在游戏中,请先退出或等待30秒后重试' }) return else: del active_usernames[identity_key] # 创建玩家 player = HumanPlayer(player_name) player.connect(request.sid) # 记录活跃玩家(使用用户名或昵称) active_usernames[identity_key] = player.id # 加入房间 success, message = room_manager.join_room(room_code, player) if not success: emit('error', { 'code': 'JOIN_FAILED', 'message': message }) return room = room_manager.get_room_by_player(player.id) # 记录用户-房间映射(支持服务器端重连) if current_user.is_authenticated: user_room_map[current_user.username] = room.id logger.info(f"User {current_user.username} mapped to room {room.code}") # 记录映射 socket_player_map[request.sid] = player.id # 加入 Socket.IO 房间 join_room(room.id) logger.info(f"Player {player_name} joined room {room_code}") # 通知自己 emit('room_joined', { 'success': True, 'room': room.to_dict(), 'player': player.to_dict(include_private=True) }) # 通知房间其他人 emit('player_joined', { 'player': player.to_dict() }, room=room.id, broadcast=True, include_self=False) @socketio.on('leave_room') def handle_leave_room(): """离开房间""" socket_id = request.sid player_id = socket_player_map.get(socket_id) if not player_id: emit('error', {'code': 'NOT_IN_ROOM', 'message': '不在任何房间中'}) return room = room_manager.get_room_by_player(player_id) if not room: emit('error', {'code': 'ROOM_NOT_FOUND', 'message': '房间不存在'}) return player = room.players.get(player_id) player_name = player.name if player else 'Unknown' # 清理活跃玩家记录(根据用户名或昵称) identity_key = current_user.username if current_user.is_authenticated else player_name if identity_key in active_usernames: del active_usernames[identity_key] # 清理用户-房间映射 if current_user.is_authenticated and current_user.username in user_room_map: del user_room_map[current_user.username] logger.info(f"User {current_user.username} room mapping cleared") # 离开房间 room_manager.leave_room(player_id) # 离开 Socket.IO 房间 leave_room(room.id) logger.info(f"Player {player_name} left room {room.code}") # 通知自己 emit('room_left', {'success': True}) # 通知房间其他人 emit('player_left', { 'player_id': player_id, 'player_name': player_name }, room=room.id, broadcast=True, include_self=False) @socketio.on('set_ready') def handle_set_ready(data: dict): """设置准备状态""" socket_id = request.sid player_id = socket_player_map.get(socket_id) if not player_id: emit('error', {'code': 'NOT_IN_ROOM', 'message': '不在任何房间中'}) return room = room_manager.get_room_by_player(player_id) if not room: emit('error', {'code': 'ROOM_NOT_FOUND', 'message': '房间不存在'}) return player = room.players.get(player_id) if not player: emit('error', {'code': 'PLAYER_NOT_FOUND', 'message': '玩家不存在'}) return ready = data.get('ready', True) player.set_ready(ready) logger.info(f"Player {player.name} ready={ready} in room {room.code}") # 广播状态更新 emit('room_updated', { 'room': room.to_dict() }, room=room.id, broadcast=True) @socketio.on('start_game') def handle_start_game(): """开始游戏(仅房主)""" socket_id = request.sid player_id = socket_player_map.get(socket_id) if not player_id: emit('error', {'code': 'NOT_IN_ROOM', 'message': '不在任何房间中'}) return room = room_manager.get_room_by_player(player_id) if not room: emit('error', {'code': 'ROOM_NOT_FOUND', 'message': '房间不存在'}) return # 检查是否是房主 if room.host_id != player_id: emit('error', {'code': 'NOT_HOST', 'message': '只有房主可以开始游戏'}) return # 开始游戏 success, message = room_manager.start_game(room.id) if not success: emit('error', {'code': 'START_FAILED', 'message': message}) return logger.info(f"Game started in room {room.code}") # 广播游戏开始 emit('game_started', { 'room': room.to_dict(include_game_state=True) }, room=room.id, broadcast=True) # 启动回合超时定时器 start_turn_timer(room.id) # 如果是斗地主且当前是AI回合,触发AI if room.game_type == 'doudizhu': check_and_trigger_ai(room.id) @socketio.on('game_action') def handle_game_action(data: dict): """处理游戏操作""" socket_id = request.sid player_id = socket_player_map.get(socket_id) if not player_id: emit('error', {'code': 'NOT_IN_ROOM', 'message': '不在任何房间中'}) return room = room_manager.get_room_by_player(player_id) if not room or not room.game: emit('error', {'code': 'GAME_NOT_STARTED', 'message': '游戏未开始'}) return action = data.get('action') action_data = data.get('data', {}) # 处理托管切换请求 if action == 'toggle_trustee': handle_toggle_trustee(room, player_id) return # 处理操作 result = room.game.handle_action(player_id, action, action_data) # 更新房间活动时间 room.update_activity() if result.get('success'): logger.info(f"Action {action} by {player_id} in room {room.code}") # 重置玩家超时计数(正常操作) reset_player_timeout(player_id) # 取消旧定时器,启动新定时器 start_turn_timer(room.id) # 广播状态更新 emit('state_update', { 'action': action, 'player_id': player_id, 'result': result, 'game_state': room.game.to_dict() }, room=room.id, broadcast=True) # 检查游戏是否结束 winner_id = room.game.check_winner() if winner_id: room.end_game() cancel_turn_timer(room.id) # 取消定时器 broadcast_game_ended(room, winner_id) else: # 触发下一个AI check_and_trigger_ai(room.id) else: # 操作失败,只通知操作者 emit('action_failed', { 'action': action, 'message': result.get('message', '操作失败') }) def calculate_and_update_scores(room, winner_id: str) -> Dict[str, Any]: """ 计算并更新积分 规则: - 地主赢:+6分,地主输:-4分 - 农民赢:+3分,农民输:-2分 """ game = room.game lord_id = game.lord_id score_changes = {} with app.app_context(): for pid, player in game.players.items(): if player.is_ai: # AI 玩家不计算积分 score_changes[pid] = {'name': player.name, 'change': 0, 'total': 0, 'is_ai': True} continue is_lord = (pid == lord_id) is_winner = (pid == winner_id) # 计算积分变动 if is_lord: change = 6 if is_winner else -4 else: change = 3 if is_winner else -2 # 更新数据库中的积分 db_user = User.query.filter_by(username=player.name).first() if db_user: new_total = db_user.add_game_score('doudizhu', change) db.session.commit() score_changes[pid] = { 'name': player.name, 'change': change, 'total': new_total, 'is_lord': is_lord, 'is_winner': is_winner, 'is_ai': False } else: # 游客或找不到用户,只记录变动 score_changes[pid] = { 'name': player.name, 'change': change, 'total': 0, 'is_lord': is_lord, 'is_winner': is_winner, 'is_ai': False, 'guest': True } return score_changes def broadcast_game_ended(room, winner_id: str, message: str = None): """广播游戏结束,包含获胜者信息和积分变动""" game = room.game winner_player = game.players.get(winner_id) # 计算并更新积分 score_changes = calculate_and_update_scores(room, winner_id) # 构建获胜者信息 winner_info = None if winner_player: winner_info = { 'id': winner_id, 'player_id': winner_id, 'name': winner_player.name, 'is_lord': (winner_id == game.lord_id) } data = { 'winner': winner_info, 'winner_id': winner_id, 'room': room.to_dict(include_game_state=True), 'scores': score_changes } if message: data['message'] = message socketio.emit('game_ended', data, room=room.id) logger.info(f"游戏结束,获胜者: {winner_player.name if winner_player else winner_id}, 积分变动: {score_changes}") def handle_toggle_trustee(room, player_id: str): """处理手动托管/取消托管""" player = room.game.players.get(player_id) if not player: emit('error', {'code': 'PLAYER_NOT_FOUND', 'message': '玩家不存在'}) return if player.is_ai: emit('error', {'code': 'AI_PLAYER', 'message': 'AI玩家不能托管'}) return if player.is_ai_controlled: # 取消托管 player.disable_ai_control() reset_player_timeout(player_id) logger.info(f"Player {player.name} disabled AI control in room {room.code}") emit('trustee_changed', { 'player_id': player_id, 'is_trustee': False, 'message': '已取消托管' }) emit('trustee_changed', { 'player_id': player_id, 'player_name': player.name, 'is_trustee': False, 'message': f'{player.name} 取消了托管' }, room=room.id, broadcast=True, include_self=False) else: # 启用托管 player.enable_ai_control() logger.info(f"Player {player.name} enabled AI control in room {room.code}") emit('trustee_changed', { 'player_id': player_id, 'is_trustee': True, 'message': '已进入托管模式' }) emit('trustee_changed', { 'player_id': player_id, 'player_name': player.name, 'is_trustee': True, 'message': f'{player.name} 进入了托管模式' }, room=room.id, broadcast=True, include_self=False) # 如果当前轮到该玩家,立即触发AI game = room.game state = game.get_state() current_turn = state.get('current_turn') or state.get('current_turn_player_id') if current_turn == player_id: socketio.start_background_task(process_ai_turn, room.id, player_id) @socketio.on('ping') def handle_ping(data: dict): """心跳""" emit('pong', { 'timestamp': data.get('timestamp'), 'server_time': datetime.now().timestamp() }) # ============ AI 处理 ============ # 存储 AI 实例 ai_instances: dict = {} # player_id -> AIAdapter # 存储活跃玩家(用于单点登录)- 使用用户名(唯一) active_usernames: dict = {} # username -> player_id def get_or_create_ai(player_id: str, ai_type: str = 'rule') -> 'AIAdapter': """获取或创建AI实例""" if player_id not in ai_instances: if ai_type == 'deepseek': from config import DEEPSEEK_API_KEY ai_instances[player_id] = create_doudizhu_ai('deepseek', api_key=DEEPSEEK_API_KEY) else: ai_instances[player_id] = create_doudizhu_ai('rule') return ai_instances[player_id] def process_ai_turn(room_id: str, player_id: str): """处理AI回合(在线程中运行)""" def run_ai(): room = room_manager.get_room(room_id) if not room or not room.game: return game = room.game player = game.players.get(player_id) if not player: return # 检查是否是AI玩家或处于AI托管模式 if not player.is_ai and not player.is_ai_controlled: return # 获取AI类型(AI托管的人类玩家使用rule类型) if player.is_ai: ai_type = getattr(player, 'ai_type', 'rule') else: ai_type = 'rule' # AI托管的人类玩家使用规则AI ai = get_or_create_ai(player_id, ai_type) # 准备游戏状态 game_state = game.to_dict() available_actions = game.get_valid_actions(player_id) # 准备玩家上下文 player_cards = game.player_cards.get(player_id, []) is_lord = game.lord_id == player_id # 计算队友手牌数(对于农民) teammate_cards = 17 if not is_lord and game.lord_id: for pid in game.players: if pid != player_id and pid != game.lord_id: teammate_cards = len(game.player_cards.get(pid, [])) break player_context = { 'cards': [c.to_dict() for c in player_cards], 'is_lord': is_lord, 'teammate_cards': teammate_cards } try: # 运行异步决策 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) decision = loop.run_until_complete( ai.make_decision(game_state, available_actions, player_context) ) loop.close() # 执行决策 action = decision['action'] action_data = decision.get('data', {}) # 添加延迟,模拟思考时间 import time time.sleep(2) result = game.handle_action(player_id, action, action_data) room.update_activity() if result.get('success'): logger.info(f"AI {player.name} action: {action}") # 重置超时计数 reset_player_timeout(player_id) # 重启回合定时器 start_turn_timer(room_id) # 使用 SocketIO 广播 with app.app_context(): socketio.emit('state_update', { 'action': action, 'player_id': player_id, 'result': result, 'game_state': game.to_dict(), 'ai_reasoning': decision.get('reasoning', '') }, room=room.id) # 检查游戏是否结束 winner_id = game.check_winner() if winner_id: room.end_game() cancel_turn_timer(room_id) # 取消定时器 with app.app_context(): broadcast_game_ended(room, winner_id) else: # 继续下一个AI next_player = game.get_current_player() if next_player and (next_player.is_ai or next_player.is_ai_controlled): threading.Timer(0.5, process_ai_turn, args=[room_id, next_player.id]).start() except Exception as e: logger.error(f"AI processing error: {e}") import traceback traceback.print_exc() # 启动线程 thread = threading.Thread(target=run_ai) thread.daemon = True thread.start() def check_and_trigger_ai(room_id: str): """检查并触发AI行动""" room = room_manager.get_room(room_id) if not room or not room.game: return game = room.game current_player = game.get_current_player() # 触发AI:真正的AI玩家 或 处于AI托管模式的人类玩家 if current_player and (current_player.is_ai or current_player.is_ai_controlled): threading.Timer(0.5, process_ai_turn, args=[room_id, current_player.id]).start() def ai_control_after_timeout(room_id: str, player_id: str, timeout: int = 30): """超时后启用AI托管""" import time time.sleep(timeout) room = room_manager.get_room(room_id) if not room: return player = room.players.get(player_id) if not player: return # 检查玩家是否已经重连(offline_since 为 None 表示已重连) if player.offline_since is None: logger.info(f"Player {player.name} has reconnected, skipping AI control") return # 如果玩家仍然离线且游戏进行中 if not player.is_connected and room.status == 'playing': player.enable_ai_control() logger.info(f"Player {player.name} AI control enabled after {timeout}s timeout") # 清理活跃玩家记录(允许该名字重新进入游戏) # 尝试用昵称清理 if player.name in active_usernames and active_usernames[player.name] == player_id: del active_usernames[player.name] # 通知房间其他玩家 with app.app_context(): socketio.emit('player_ai_controlled', { 'player_id': player_id, 'player_name': player.name, 'message': f'{player.name} 已启用AI托管' }, room=room.id) # 如果当前是该玩家回合,触发AI if room.game and room.game.get_current_player() and room.game.get_current_player().id == player_id: check_and_trigger_ai(room_id) @socketio.on('reconnect_room') def handle_reconnect_room(data: dict): """断线重连""" socket_id = request.sid room_code = data.get('room_code') player_id = data.get('player_id') player_name = data.get('player_name', '玩家') # 如果没有提供 room_code,尝试从服务器端查找(登录用户) if not room_code and current_user.is_authenticated: username = current_user.username if username in user_room_map: room_id = user_room_map[username] room = room_manager.get_room(room_id=room_id) if room: room_code = room.code logger.info(f"Auto-resolved room_code {room_code} for user {username}") if not room_code: emit('error', {'code': 'MISSING_ROOM_CODE', 'message': '缺少房间号'}) return room = room_manager.get_room(room_code=room_code) if not room: # 房间不存在,清理用户-房间映射 if current_user.is_authenticated and current_user.username in user_room_map: del user_room_map[current_user.username] logger.info(f"Cleared stale room mapping for {current_user.username}") emit('error', {'code': 'ROOM_NOT_FOUND', 'message': '房间不存在或已关闭'}) return # 尝试找到原玩家 player = None # 对于已登录用户,优先通过 username 查找(支持跨浏览器重连) if current_user.is_authenticated: username = current_user.username for p in room.players.values(): if p.name == username: player = p logger.info(f"Reconnected by username: {username}") break # 未登录用户或未找到,尝试用 player_id if not player and player_id and player_id in room.players: player = room.players[player_id] logger.info(f"Reconnected by player_id: {player_id}") # 最后用 player_name 匹配 if not player: for p in room.players.values(): if p.name == player_name: # 检查该玩家是否已有其他活跃连接 if not p.is_connected: # 玩家已断开,可以重连 player = p logger.info(f"Reconnected by player_name (disconnected): {player_name}") break elif player_name not in active_usernames: # 玩家显示连接中,但没有活跃记录,可能是残留状态 player = p player.disconnect() # 强制重置状态 logger.info(f"Reconnected by player_name (stale state): {player_name}") break if not player: logger.warning(f"Player not found for reconnect: player_id={player_id}, name={player_name}") emit('error', {'code': 'PLAYER_NOT_FOUND', 'message': '无法找到原玩家,可能房间已满或游戏已结束'}) return # 单点登录检查:确保没有其他活跃连接使用相同身份 identity_key = current_user.username if current_user.is_authenticated else player.name if identity_key in active_usernames: existing_player_id = active_usernames[identity_key] # 如果活跃记录不是当前要重连的玩家,说明有其他连接在使用 if existing_player_id != player.id: existing_room = room_manager.get_room_by_player(existing_player_id) if existing_room: existing_player = existing_room.players.get(existing_player_id) if existing_player and existing_player.is_connected: identity_type = "账号" if current_user.is_authenticated else "昵称" emit('error', { 'code': 'ALREADY_ONLINE', 'message': f'{identity_type} "{identity_key}" 已在游戏中,请先退出或等待30秒后重试' }) return # 旧连接已断开,清理记录 del active_usernames[identity_key] # 重新连接玩家 player.connect(socket_id) was_ai_controlled = player.is_ai_controlled player.disable_ai_control() # 取消AI托管 # 如果之前是托管状态,广播取消托管(让所有人知道玩家回来了) if was_ai_controlled: socketio.emit('trustee_changed', { 'player_id': player.id, 'player_name': player.name, 'is_trustee': False, 'message': f'{player.name} 重连并取消了托管' }, room=room.id, broadcast=True) logger.info(f"Player {player.name} reconnected and AI control disabled") # 重置超时计数 reset_player_timeout(player.id) # 重新注册活跃玩家(使用用户名或昵称) identity_key = current_user.username if current_user.is_authenticated else player.name active_usernames[identity_key] = player.id # 更新映射 socket_player_map[socket_id] = player.id # 加入Socket.IO房间 join_room(room.id) logger.info(f"Player {player.name} reconnected to room {room.code}") # 检查游戏是否应该已经结束(防止残局) if room.game and not room.game.is_ended: # 检查是否有玩家已经出完所有牌 for pid, cards in room.game.player_cards.items(): if len(cards) == 0: # 有人没牌了,结束游戏 logger.info(f"检测到玩家 {pid} 无牌,自动结束游戏") room.game.end_game(pid) room.end_game() cancel_turn_timer(room.id) broadcast_game_ended(room, pid, '游戏已结束(检测到无牌玩家)') break # 通知重连成功 emit('reconnect_success', { 'player_id': player.id, 'player': player.to_dict(), 'room': room.to_dict(include_game_state=True), 'was_ai_controlled': was_ai_controlled, 'message': '重连成功,欢迎回到游戏!' }) # 通知房间其他玩家 emit('player_reconnected', { 'player_id': player.id, 'player_name': player.name, 'was_ai_controlled': was_ai_controlled, 'message': f'{player.name} 已回到游戏' }, room=room.id, broadcast=True, include_self=False) # ============ 回合超时管理 ============ def start_turn_timer(room_id: str): """启动回合超时定时器""" # 取消已有的定时器 cancel_turn_timer(room_id) # 启动新定时器 timer = threading.Timer(TURN_TIMEOUT_SECONDS, handle_turn_timeout, args=[room_id]) timer.daemon = True timer.start() room_turn_timers[room_id] = timer logger.debug(f"Room {room_id} turn timer started ({TURN_TIMEOUT_SECONDS}s)") def cancel_turn_timer(room_id: str): """取消回合超时定时器""" if room_id in room_turn_timers: room_turn_timers[room_id].cancel() del room_turn_timers[room_id] logger.debug(f"Room {room_id} turn timer cancelled") def handle_turn_timeout(room_id: str): """处理回合超时""" room = room_manager.get_room(room_id) if not room or not room.game: return game = room.game current_player = game.get_current_player() if not current_player: return player_id = current_player.id player_name = current_player.name # 增加超时次数 player_timeout_count[player_id] = player_timeout_count.get(player_id, 0) + 1 timeout_count = player_timeout_count[player_id] logger.info(f"Player {player_name} timeout #{timeout_count} in room {room.code}") # 检查是否超过最大超时次数 if timeout_count >= MAX_TIMEOUT_COUNT: # 启用AI托管 current_player.enable_ai_control() logger.info(f"Player {player_name} AI control enabled after {timeout_count} timeouts") # 通知所有人 with app.app_context(): socketio.emit('player_ai_controlled', { 'player_id': player_id, 'player_name': player_name, 'reason': 'timeout', 'message': f'{player_name} 超时{timeout_count}次,已启用AI托管' }, room=room.id) # 触发AI出牌 check_and_trigger_ai(room_id) else: # 执行自动pass(不要) _execute_auto_pass(room, game, player_id, player_name, timeout_count) def _execute_auto_pass(room, game, player_id: str, player_name: str, timeout_count: int): """执行自动不出""" try: # 检查是否可以pass(出牌阶段且不是必须出牌时) if game.doudizhu_phase.value != 'playing': # 叫地主阶段超时,默认不叫 result = game.handle_action(player_id, 'pass', {'want': False}) action_name = '不叫' else: # 出牌阶段超时 # 检查是否必须出牌(自己最后出牌时不能pass) if game.last_played_id == player_id: # 必须出牌,但超时了,启用AI托管处理 logger.info(f"Player {player_name} must play but timeout, enabling AI control") player = game.players.get(player_id) if player: player.enable_ai_control() with app.app_context(): socketio.emit('player_ai_controlled', { 'player_id': player_id, 'player_name': player_name, 'reason': 'must_play_timeout', 'message': f'{player_name} 必须出牌但超时,已启用AI托管' }, room=room.id) check_and_trigger_ai(room.id) return else: # 可以pass result = game.handle_action(player_id, 'pass', {}) action_name = '不要' if result.get('success'): room.update_activity() # 广播超时自动操作 with app.app_context(): socketio.emit('turn_timeout_auto', { 'player_id': player_id, 'player_name': player_name, 'action': 'pass', 'action_name': action_name, 'timeout_count': timeout_count, 'game_state': game.to_dict(), 'message': f'{player_name} 超时,自动{action_name} ({timeout_count}/{MAX_TIMEOUT_COUNT})' }, room=room.id) logger.info(f"Auto {action_name} executed for {player_name}") # 检查游戏是否结束 winner_id = game.check_winner() if winner_id: room.end_game() with app.app_context(): broadcast_game_ended(room, winner_id) cancel_turn_timer(room.id) else: # 继续下一个回合的定时器 start_turn_timer(room.id) # 如果下一个是AI,触发AI check_and_trigger_ai(room.id) else: logger.warning(f"Auto pass failed for {player_name}: {result.get('message')}") except Exception as e: logger.error(f"Error in auto pass: {e}") import traceback traceback.print_exc() def reset_player_timeout(player_id: str): """重置玩家超时计数(玩家正常操作时调用)""" if player_id in player_timeout_count: del player_timeout_count[player_id] logger.debug(f"Player {player_id} timeout count reset") # ============ 房间清理任务 ============ def cleanup_rooms_periodically(): """定期清理不活跃房间""" import time as time_module while True: try: time_module.sleep(300) # 每5分钟检查一次 removed = room_manager.cleanup_inactive_rooms(max_inactive_minutes=30) if removed > 0: logger.info(f"🧹 清理了 {removed} 个不活跃房间") except Exception as e: logger.error(f"清理房间时出错: {e}") def start_cleanup_thread(): """启动清理线程""" cleanup_thread = threading.Thread(target=cleanup_rooms_periodically, daemon=True) cleanup_thread.start() logger.info("🧹 房间清理线程已启动") # ============ Main ============ def main(): """启动服务器""" # 初始化数据库 with app.app_context(): db.create_all() init_default_users() logger.info("✅ 数据库初始化完成") # 启动清理线程 start_cleanup_thread() logger.info(f"Starting Net-Games server on {HOST}:{PORT}") logger.info(f"Game Hub: http://{HOST}:{PORT}/hub/") logger.info(f"Debug mode: {DEBUG}") socketio.run( app, host=HOST, port=PORT, debug=DEBUG, use_reloader=DEBUG, allow_unsafe_werkzeug=True ) if __name__ == '__main__': main()