1772 lines
59 KiB
Python
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()
|