Files
nanocode/net-games/server.py
T
2026-08-04 16:00:01 +08:00

1772 lines
59 KiB
Python

"""
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/<path:filename>')
def doudizhu_static(filename):
"""斗地主游戏静态文件"""
return send_from_directory(CLIENT_DIR, filename)
@app.route('/<path:filename>')
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/<room_code>')
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/<room_code>', 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()