forked from xiangwang25/whale-town-end-v2
feat: integrate invitation access, world NPCs, and deployment
This commit is contained in:
@@ -71,6 +71,12 @@ export class LoginDto {
|
||||
* 注册请求DTO
|
||||
*/
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ description: '邀请码', example: 'WT-ABCD-EFGH-IJKL' })
|
||||
@IsString({ message: '邀请码必须是字符串' })
|
||||
@IsNotEmpty({ message: '邀请码不能为空' })
|
||||
@Length(8, 30, { message: '邀请码格式不正确' })
|
||||
invitation_code: string;
|
||||
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
@@ -383,12 +389,9 @@ export class EmailVerificationDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码请求DTO
|
||||
* 邮箱地址请求DTO
|
||||
*/
|
||||
export class SendEmailVerificationDto {
|
||||
/**
|
||||
* 邮箱地址
|
||||
*/
|
||||
export class EmailAddressDto {
|
||||
@ApiProperty({
|
||||
description: '邮箱地址',
|
||||
example: 'test@example.com'
|
||||
@@ -398,6 +401,17 @@ export class SendEmailVerificationDto {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码请求DTO
|
||||
*/
|
||||
export class SendEmailVerificationDto extends EmailAddressDto {
|
||||
@ApiProperty({ description: '邀请码', example: 'WT-ABCD-EFGH-IJKL' })
|
||||
@IsString({ message: '邀请码必须是字符串' })
|
||||
@IsNotEmpty({ message: '邀请码不能为空' })
|
||||
@Length(8, 30, { message: '邀请码格式不正确' })
|
||||
invitation_code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录请求DTO
|
||||
*/
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
VerificationCodeLoginDto,
|
||||
SendLoginVerificationCodeDto,
|
||||
RefreshTokenDto,
|
||||
SendEmailVerificationDto
|
||||
EmailAddressDto,
|
||||
} from './dto/login.dto';
|
||||
import {
|
||||
LoginResponseDto,
|
||||
@@ -490,11 +490,11 @@ export class LoginController {
|
||||
summary: '调试验证码信息',
|
||||
description: '获取验证码的详细调试信息(仅开发环境)'
|
||||
})
|
||||
@ApiBody({ type: SendEmailVerificationDto })
|
||||
@ApiBody({ type: EmailAddressDto })
|
||||
@Post('debug-verification-code')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async debugVerificationCode(
|
||||
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
|
||||
@Body() sendEmailVerificationDto: EmailAddressDto,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const result = await this.loginService.debugVerificationCode(sendEmailVerificationDto.email);
|
||||
|
||||
240
src/gateway/auth/register.controller.spec.ts
Normal file
240
src/gateway/auth/register.controller.spec.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* RegisterController 单元测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试注册控制器的HTTP请求处理
|
||||
* - 验证API响应格式和状态码
|
||||
* - 测试邮箱验证流程
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 架构重构 - 从business层移动到gateway层 (修改者: moyin)
|
||||
* - 2026-01-12: 代码规范优化 - 创建缺失的控制器测试文件 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-12
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Response } from 'express';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { RegisterController } from './register.controller';
|
||||
import { RegisterService } from '../../business/auth/register.service';
|
||||
|
||||
describe('RegisterController', () => {
|
||||
let controller: RegisterController;
|
||||
let registerService: jest.Mocked<RegisterService>;
|
||||
let mockResponse: jest.Mocked<Response>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockRegisterService = {
|
||||
register: jest.fn(),
|
||||
sendEmailVerification: jest.fn(),
|
||||
verifyEmailCode: jest.fn(),
|
||||
resendEmailVerification: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [RegisterController],
|
||||
providers: [
|
||||
{
|
||||
provide: RegisterService,
|
||||
useValue: mockRegisterService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<RegisterController>(RegisterController);
|
||||
registerService = module.get(RegisterService);
|
||||
|
||||
// Mock Response object
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
} as any;
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should handle successful registration', async () => {
|
||||
const registerDto = {
|
||||
invitation_code: 'WT-TEST-CODE-0001',
|
||||
username: 'newuser',
|
||||
password: 'password123',
|
||||
nickname: '新用户',
|
||||
email: 'newuser@example.com',
|
||||
email_verification_code: '123456',
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'newuser',
|
||||
nickname: '新用户',
|
||||
role: 1,
|
||||
created_at: new Date()
|
||||
},
|
||||
access_token: 'token',
|
||||
refresh_token: 'refresh_token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
is_new_user: true,
|
||||
message: '注册成功'
|
||||
},
|
||||
message: '注册成功'
|
||||
};
|
||||
|
||||
registerService.register.mockResolvedValue(mockResult);
|
||||
|
||||
await controller.register(registerDto, mockResponse);
|
||||
|
||||
expect(registerService.register).toHaveBeenCalledWith(registerDto);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CREATED);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
|
||||
});
|
||||
|
||||
it('should handle registration failure', async () => {
|
||||
const registerDto = {
|
||||
invitation_code: 'WT-TEST-CODE-0001',
|
||||
username: 'existinguser',
|
||||
password: 'password123',
|
||||
nickname: '用户',
|
||||
email: 'existing@example.com',
|
||||
email_verification_code: '123456',
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
success: false,
|
||||
message: '用户名已存在',
|
||||
error_code: 'REGISTER_FAILED'
|
||||
};
|
||||
|
||||
registerService.register.mockResolvedValue(mockResult);
|
||||
|
||||
await controller.register(registerDto, mockResponse);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendEmailVerification', () => {
|
||||
it('should handle email verification in production mode', async () => {
|
||||
const sendEmailDto = {
|
||||
email: 'test@example.com',
|
||||
invitation_code: 'WT-TEST-CODE-0001',
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
success: true,
|
||||
data: { is_test_mode: false },
|
||||
message: '验证码已发送,请查收邮件'
|
||||
};
|
||||
|
||||
registerService.sendEmailVerification.mockResolvedValue(mockResult);
|
||||
|
||||
await controller.sendEmailVerification(sendEmailDto, mockResponse);
|
||||
|
||||
expect(registerService.sendEmailVerification).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
'WT-TEST-CODE-0001',
|
||||
);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
|
||||
});
|
||||
|
||||
it('should handle email verification in test mode', async () => {
|
||||
const sendEmailDto = {
|
||||
email: 'test@example.com',
|
||||
invitation_code: 'WT-TEST-CODE-0001',
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: '123456',
|
||||
is_test_mode: true
|
||||
},
|
||||
message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
error_code: 'TEST_MODE_ONLY'
|
||||
};
|
||||
|
||||
registerService.sendEmailVerification.mockResolvedValue(mockResult);
|
||||
|
||||
await controller.sendEmailVerification(sendEmailDto, mockResponse);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.PARTIAL_CONTENT);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyEmail', () => {
|
||||
it('should handle email verification successfully', async () => {
|
||||
const verifyEmailDto = {
|
||||
email: 'test@example.com',
|
||||
verification_code: '123456'
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
success: true,
|
||||
message: '邮箱验证成功'
|
||||
};
|
||||
|
||||
registerService.verifyEmailCode.mockResolvedValue(mockResult);
|
||||
|
||||
await controller.verifyEmail(verifyEmailDto, mockResponse);
|
||||
|
||||
expect(registerService.verifyEmailCode).toHaveBeenCalledWith('test@example.com', '123456');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
|
||||
});
|
||||
|
||||
it('should handle invalid verification code', async () => {
|
||||
const verifyEmailDto = {
|
||||
email: 'test@example.com',
|
||||
verification_code: '000000'
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
success: false,
|
||||
message: '验证码错误',
|
||||
error_code: 'INVALID_VERIFICATION_CODE'
|
||||
};
|
||||
|
||||
registerService.verifyEmailCode.mockResolvedValue(mockResult);
|
||||
|
||||
await controller.verifyEmail(verifyEmailDto, mockResponse);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resendEmailVerification', () => {
|
||||
it('should handle resend email verification successfully', async () => {
|
||||
const sendEmailDto = {
|
||||
email: 'test@example.com'
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
success: true,
|
||||
data: { is_test_mode: false },
|
||||
message: '验证码已重新发送,请查收邮件'
|
||||
};
|
||||
|
||||
registerService.resendEmailVerification.mockResolvedValue(mockResult);
|
||||
|
||||
await controller.resendEmailVerification(sendEmailDto, mockResponse);
|
||||
|
||||
expect(registerService.resendEmailVerification).toHaveBeenCalledWith('test@example.com');
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,8 @@ import { RegisterService } from '../../business/auth/register.service';
|
||||
import {
|
||||
RegisterDto,
|
||||
EmailVerificationDto,
|
||||
SendEmailVerificationDto
|
||||
SendEmailVerificationDto,
|
||||
EmailAddressDto,
|
||||
} from './dto/login.dto';
|
||||
import {
|
||||
RegisterResponseDto,
|
||||
@@ -166,7 +167,8 @@ export class RegisterController {
|
||||
email: registerDto.email,
|
||||
phone: registerDto.phone,
|
||||
skin_id: registerDto.skin_id,
|
||||
email_verification_code: registerDto.email_verification_code
|
||||
email_verification_code: registerDto.email_verification_code,
|
||||
invitation_code: registerDto.invitation_code,
|
||||
});
|
||||
|
||||
this.handleResponse(result, res, HttpStatus.CREATED);
|
||||
@@ -209,7 +211,7 @@ export class RegisterController {
|
||||
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const result = await this.registerService.sendEmailVerification(sendEmailVerificationDto.email);
|
||||
const result = await this.registerService.sendEmailVerification(sendEmailVerificationDto.email, sendEmailVerificationDto.invitation_code);
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
@@ -254,7 +256,7 @@ export class RegisterController {
|
||||
summary: '重新发送邮箱验证码',
|
||||
description: '重新向指定邮箱发送验证码'
|
||||
})
|
||||
@ApiBody({ type: SendEmailVerificationDto })
|
||||
@ApiBody({ type: EmailAddressDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '验证码重新发送成功',
|
||||
@@ -277,7 +279,7 @@ export class RegisterController {
|
||||
@Post('resend-email-verification')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async resendEmailVerification(
|
||||
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
|
||||
@Body() sendEmailVerificationDto: EmailAddressDto,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const result = await this.registerService.resendEmailVerification(sendEmailVerificationDto.email);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
HttpStatus,
|
||||
HttpException,
|
||||
Logger,
|
||||
Headers,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
import { JwtAuthGuard } from '../auth/jwt_auth.guard';
|
||||
import { ChatService } from '../../business/chat/chat.service';
|
||||
import { ChatWebSocketGateway } from './chat.gateway';
|
||||
import { WorldNpcService } from '../../business/world_npc/world_npc.service';
|
||||
import { SendChatMessageDto, GetChatHistoryDto } from './chat.dto';
|
||||
import {
|
||||
ChatMessageResponseDto,
|
||||
@@ -67,8 +69,37 @@ export class ChatController {
|
||||
constructor(
|
||||
private readonly chatService: ChatService,
|
||||
private readonly websocketGateway: ChatWebSocketGateway,
|
||||
private readonly worldNpcService: WorldNpcService,
|
||||
) {}
|
||||
|
||||
@Get('world-npcs/status')
|
||||
@ApiOperation({ summary: '查看AI小镇NPC的当前计划、位置与动作状态' })
|
||||
getWorldNpcStatus() {
|
||||
return this.worldNpcService.getTownStatus();
|
||||
}
|
||||
|
||||
@Post('world-npcs/test-time')
|
||||
@ApiOperation({ summary: '开发环境设置AI小镇测试时间' })
|
||||
async setWorldNpcTestTime(
|
||||
@Body() body: { timestamp?: number | string | null },
|
||||
@Headers('x-world-npc-test-token') token?: string,
|
||||
) {
|
||||
const enabled = process.env.NODE_ENV !== 'production'
|
||||
&& process.env.WORLD_NPC_TEST_CONTROLS === 'enabled';
|
||||
const expectedToken = String(process.env.WORLD_NPC_TEST_CONTROL_TOKEN || '').trim();
|
||||
if (!enabled || !expectedToken || token !== expectedToken) {
|
||||
throw new HttpException('测试时间控制未启用', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (body.timestamp === null || body.timestamp === undefined || body.timestamp === '') {
|
||||
return this.worldNpcService.setTownTimeForTesting(undefined);
|
||||
}
|
||||
const numeric = typeof body.timestamp === 'number'
|
||||
? body.timestamp
|
||||
: Date.parse(String(body.timestamp));
|
||||
if (!Number.isFinite(numeric)) throw new HttpException('timestamp无效', HttpStatus.BAD_REQUEST);
|
||||
return this.worldNpcService.setTownTimeForTesting(numeric);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息(REST API 方式)
|
||||
*
|
||||
@@ -91,7 +122,7 @@ export class ChatController {
|
||||
|
||||
// REST API 没有 WebSocket 连接,提示使用 WebSocket
|
||||
throw new HttpException(
|
||||
'聊天消息发送需要通过 WebSocket 连接。请使用 WebSocket 接口:wss://whaletownend.xinghangee.icu/game',
|
||||
'聊天消息发送需要通过 WebSocket 连接。请使用 WebSocket 接口:wss://whaletown.novamailio.com/game',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
@@ -180,7 +211,7 @@ export class ChatController {
|
||||
@ApiOperation({ summary: '获取 WebSocket 连接信息' })
|
||||
async getWebSocketInfo() {
|
||||
return {
|
||||
websocketUrl: 'wss://whaletownend.xinghangee.icu/game',
|
||||
websocketUrl: 'wss://whaletown.novamailio.com/game',
|
||||
protocol: 'native-websocket',
|
||||
path: '/game',
|
||||
supportedEvents: ['login', 'chat', 'position'],
|
||||
|
||||
@@ -25,6 +25,7 @@ import { ChatController } from './chat.controller';
|
||||
import { ChatWebSocketGateway } from './chat.gateway';
|
||||
import { ChatModule } from '../../business/chat/chat.module';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { WorldNpcModule } from '../../business/world_npc/world_npc.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -32,6 +33,7 @@ import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
ChatModule,
|
||||
// 登录核心模块 - 用于 JWT 验证
|
||||
LoginCoreModule,
|
||||
WorldNpcModule,
|
||||
],
|
||||
controllers: [
|
||||
ChatController,
|
||||
|
||||
460
src/gateway/chat/chat.gateway.spec.ts
Normal file
460
src/gateway/chat/chat.gateway.spec.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* 聊天 WebSocket 网关单元测试
|
||||
*
|
||||
* 功能描述:
|
||||
* - 测试 ChatWebSocketGateway 的 WebSocket 连接管理
|
||||
* - 验证消息路由和处理逻辑
|
||||
* - 测试房间管理和广播功能
|
||||
*
|
||||
* 测试范围:
|
||||
* - onModuleInit() - 模块初始化
|
||||
* - onModuleDestroy() - 模块销毁
|
||||
* - getConnectionCount() - 获取连接数
|
||||
* - getAuthenticatedConnectionCount() - 获取认证连接数
|
||||
* - getMapPlayerCounts() - 获取地图玩家数
|
||||
* - getMapPlayers() - 获取地图玩家列表
|
||||
* - sendToPlayer() - 单播消息
|
||||
* - broadcastToMap() - 地图广播
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ChatWebSocketGateway } from './chat.gateway';
|
||||
import { ChatService } from '../../business/chat/chat.service';
|
||||
import { WorldNpcService } from '../../business/world_npc/world_npc.service';
|
||||
|
||||
// Mock ws module
|
||||
jest.mock('ws', () => {
|
||||
const mockServerInstance = {
|
||||
on: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
const MockServer = jest.fn(() => mockServerInstance);
|
||||
|
||||
return {
|
||||
Server: MockServer,
|
||||
OPEN: 1,
|
||||
__mockServerInstance: mockServerInstance,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ChatWebSocketGateway', () => {
|
||||
let gateway: ChatWebSocketGateway;
|
||||
let mockChatService: jest.Mocked<Partial<ChatService>>;
|
||||
let mockWorldNpcService: jest.Mocked<Partial<WorldNpcService>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockChatService = {
|
||||
setWebSocketGateway: jest.fn(),
|
||||
handlePlayerLogin: jest.fn(),
|
||||
handlePlayerLogout: jest.fn(),
|
||||
sendChatMessage: jest.fn(),
|
||||
updatePlayerPosition: jest.fn(),
|
||||
updatePlayerPositionAndGetPresence: jest.fn(),
|
||||
getSession: jest.fn(),
|
||||
getMapPlayerSnapshot: jest.fn(),
|
||||
refreshPlayerAppearance: jest.fn(),
|
||||
};
|
||||
mockWorldNpcService = {
|
||||
getMapSnapshot: jest.fn().mockImplementation((mapId: string) => ({
|
||||
mapId,
|
||||
serverNow: 1,
|
||||
version: 0,
|
||||
npcs: [],
|
||||
})),
|
||||
tick: jest.fn().mockResolvedValue({
|
||||
completed: [],
|
||||
started: [],
|
||||
conversations: [],
|
||||
changedMaps: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatWebSocketGateway,
|
||||
{ provide: ChatService, useValue: mockChatService },
|
||||
{ provide: WorldNpcService, useValue: mockWorldNpcService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
gateway = module.get<ChatWebSocketGateway>(ChatWebSocketGateway);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('onModuleInit', () => {
|
||||
it('should initialize WebSocket server and set gateway reference', async () => {
|
||||
await gateway.onModuleInit();
|
||||
|
||||
expect(mockChatService.setWebSocketGateway).toHaveBeenCalledWith(gateway);
|
||||
});
|
||||
|
||||
it('should use default port 3001 when WEBSOCKET_PORT is not set', async () => {
|
||||
delete process.env.WEBSOCKET_PORT;
|
||||
|
||||
await gateway.onModuleInit();
|
||||
|
||||
// Verify server was created (mock was called)
|
||||
const ws = require('ws');
|
||||
expect(ws.Server).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 3001,
|
||||
path: '/game',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom port from environment variable', async () => {
|
||||
process.env.WEBSOCKET_PORT = '4000';
|
||||
|
||||
// Create new gateway instance to pick up env change
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatWebSocketGateway,
|
||||
{ provide: ChatService, useValue: mockChatService },
|
||||
{ provide: WorldNpcService, useValue: mockWorldNpcService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const newGateway = module.get<ChatWebSocketGateway>(ChatWebSocketGateway);
|
||||
await newGateway.onModuleInit();
|
||||
|
||||
const ws = require('ws');
|
||||
expect(ws.Server).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 4000,
|
||||
path: '/game',
|
||||
})
|
||||
);
|
||||
|
||||
delete process.env.WEBSOCKET_PORT;
|
||||
});
|
||||
});
|
||||
|
||||
describe('onModuleDestroy', () => {
|
||||
it('should close WebSocket server when it exists', async () => {
|
||||
await gateway.onModuleInit();
|
||||
await gateway.onModuleDestroy();
|
||||
|
||||
const ws = require('ws');
|
||||
expect(ws.__mockServerInstance.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw when server does not exist', async () => {
|
||||
// Don't call onModuleInit, so server is undefined
|
||||
await expect(gateway.onModuleDestroy()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnectionCount', () => {
|
||||
it('should return 0 when no clients connected', () => {
|
||||
expect(gateway.getConnectionCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthenticatedConnectionCount', () => {
|
||||
it('should return 0 when no authenticated clients', () => {
|
||||
expect(gateway.getAuthenticatedConnectionCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMapPlayerCounts', () => {
|
||||
it('should return empty object when no rooms exist', () => {
|
||||
expect(gateway.getMapPlayerCounts()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMapPlayers', () => {
|
||||
it('should return empty array for non-existent room', () => {
|
||||
expect(gateway.getMapPlayers('non_existent_map')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendToPlayer', () => {
|
||||
it('should not throw when client does not exist', () => {
|
||||
expect(() => {
|
||||
gateway.sendToPlayer('non_existent_id', { type: 'test' });
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcastToMap', () => {
|
||||
it('should not throw when room does not exist', () => {
|
||||
expect(() => {
|
||||
gateway.broadcastToMap('non_existent_map', { type: 'test' });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle excludeId parameter', () => {
|
||||
expect(() => {
|
||||
gateway.broadcastToMap('non_existent_map', { type: 'test' }, 'exclude_id');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcastToAll', () => {
|
||||
it('should not throw when no clients connected', () => {
|
||||
expect(() => {
|
||||
gateway.broadcastToAll({ type: 'test' });
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IChatWebSocketGateway interface', () => {
|
||||
it('should implement all interface methods', () => {
|
||||
expect(typeof gateway.sendToPlayer).toBe('function');
|
||||
expect(typeof gateway.broadcastToMap).toBe('function');
|
||||
expect(typeof gateway.broadcastToAll).toBe('function');
|
||||
expect(typeof gateway.getConnectionCount).toBe('function');
|
||||
expect(typeof gateway.getAuthenticatedConnectionCount).toBe('function');
|
||||
expect(typeof gateway.getMapPlayerCounts).toBe('function');
|
||||
expect(typeof gateway.getMapPlayers).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('world ready presence flow', () => {
|
||||
const createClient = (id: string) => ({
|
||||
id,
|
||||
readyState: 1,
|
||||
authenticated: false,
|
||||
worldReady: false,
|
||||
send: jest.fn(),
|
||||
});
|
||||
|
||||
it('waits for world_ready before announcing and joining a map room', async () => {
|
||||
const client = createClient('socket_test') as any;
|
||||
const broadcastToMap = jest.spyOn(gateway, 'broadcastToMap');
|
||||
(gateway as any).clients.set(client.id, client);
|
||||
mockChatService.handlePlayerLogin!.mockResolvedValue({
|
||||
success: true,
|
||||
sessionId: 'session_test',
|
||||
userId: '2',
|
||||
username: 'test',
|
||||
currentMap: 'whale_port',
|
||||
} as any);
|
||||
mockChatService.updatePlayerPosition!.mockResolvedValue(true);
|
||||
mockChatService.getMapPlayerSnapshot!.mockResolvedValue([]);
|
||||
mockChatService.refreshPlayerAppearance!.mockResolvedValue({
|
||||
userId: '2',
|
||||
username: 'test',
|
||||
mapId: 'whale_port',
|
||||
x: 120,
|
||||
y: 240,
|
||||
appearance: {
|
||||
skinId: 'girl_sailor_turnaround_v2_8x4',
|
||||
},
|
||||
} as any);
|
||||
mockChatService.getSession!.mockResolvedValue({
|
||||
socketId: client.id,
|
||||
userId: '2',
|
||||
username: 'test',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 120, y: 240 },
|
||||
appearance: {
|
||||
skinId: 'generated_skin_test',
|
||||
skinAsset: {
|
||||
id: 'generated_skin_test',
|
||||
texture_url: '/assets/account/2/skins/generated_skin_test.png',
|
||||
hframes: 8,
|
||||
vframes: 4,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
await (gateway as any).routeMessage(client, { type: 'login', token: 'token' });
|
||||
expect(gateway.getMapPlayerCounts()).toEqual({});
|
||||
|
||||
await (gateway as any).routeMessage(client, {
|
||||
type: 'world_ready',
|
||||
mapId: 'whale_port',
|
||||
x: 120,
|
||||
y: 240,
|
||||
});
|
||||
|
||||
expect(gateway.getMapPlayerCounts()).toEqual({ whale_port: 1 });
|
||||
expect(mockChatService.refreshPlayerAppearance).toHaveBeenCalledWith(client.id);
|
||||
expect(broadcastToMap).toHaveBeenCalledWith(
|
||||
'whale_port',
|
||||
expect.objectContaining({
|
||||
t: 'player_joined',
|
||||
skinId: 'girl_sailor_turnaround_v2_8x4',
|
||||
}),
|
||||
client.id,
|
||||
);
|
||||
const sent = client.send.mock.calls.map(([payload]) => JSON.parse(payload));
|
||||
expect(sent).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ t: 'world_ready_success', mapId: 'whale_port' }),
|
||||
expect.objectContaining({ t: 'map_players_snapshot', players: [] }),
|
||||
expect.objectContaining({ t: 'system_presence', username: 'test', scope: 'global' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('does not accept legacy base64 skin data from position messages', async () => {
|
||||
const client = Object.assign(createClient('socket_test'), {
|
||||
authenticated: true,
|
||||
worldReady: true,
|
||||
userId: '2',
|
||||
username: 'test',
|
||||
currentMap: 'whale_port',
|
||||
}) as any;
|
||||
(gateway as any).clients.set(client.id, client);
|
||||
(gateway as any).joinMapRoom(client.id, 'whale_port');
|
||||
mockChatService.updatePlayerPositionAndGetPresence!.mockResolvedValue({
|
||||
socketId: client.id,
|
||||
userId: '2',
|
||||
username: 'test',
|
||||
mapId: 'whale_port',
|
||||
x: 10,
|
||||
y: 20,
|
||||
skinId: 'generated_skin_test',
|
||||
skinAsset: { id: 'generated_skin_test', texture_url: '/skin.png' },
|
||||
direction: 'down',
|
||||
movementState: 'walk',
|
||||
sequence: 1,
|
||||
} as any);
|
||||
|
||||
await (gateway as any).routeMessage(client, {
|
||||
type: 'position',
|
||||
mapId: 'whale_port',
|
||||
x: 10,
|
||||
y: 20,
|
||||
skinAsset: { id: 'forged_skin', texture_base64: 'very-large-payload' },
|
||||
});
|
||||
|
||||
expect(mockChatService.updatePlayerPositionAndGetPresence).toHaveBeenCalledWith({
|
||||
socketId: client.id,
|
||||
mapId: 'whale_port',
|
||||
x: 10,
|
||||
y: 20,
|
||||
direction: 'down',
|
||||
movementState: 'walk',
|
||||
sequence: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts a refreshed appearance and acknowledges the initiating client', async () => {
|
||||
const client = Object.assign(createClient('socket_wangx'), {
|
||||
authenticated: true,
|
||||
worldReady: true,
|
||||
userId: '1',
|
||||
username: 'wangx',
|
||||
currentMap: 'whale_port',
|
||||
}) as any;
|
||||
const observer = Object.assign(createClient('socket_test'), {
|
||||
authenticated: true,
|
||||
worldReady: true,
|
||||
userId: '2',
|
||||
username: 'test',
|
||||
currentMap: 'whale_port',
|
||||
}) as any;
|
||||
(gateway as any).clients.set(client.id, client);
|
||||
(gateway as any).clients.set(observer.id, observer);
|
||||
(gateway as any).joinMapRoom(client.id, 'whale_port');
|
||||
(gateway as any).joinMapRoom(observer.id, 'whale_port');
|
||||
mockChatService.refreshPlayerAppearance!.mockResolvedValue({
|
||||
userId: '1',
|
||||
username: 'wangx',
|
||||
mapId: 'whale_port',
|
||||
x: 400,
|
||||
y: 300,
|
||||
appearance: { skinId: 'classic_whale' },
|
||||
skinId: 'classic_whale',
|
||||
} as any);
|
||||
|
||||
await (gateway as any).routeMessage(client, { type: 'appearance_changed' });
|
||||
|
||||
expect(mockChatService.refreshPlayerAppearance).toHaveBeenCalledWith(client.id);
|
||||
const observerMessages = observer.send.mock.calls.map(([payload]) => JSON.parse(payload));
|
||||
expect(observerMessages).toContainEqual(expect.objectContaining({
|
||||
t: 'appearance_changed',
|
||||
userId: '1',
|
||||
skinId: 'classic_whale',
|
||||
}));
|
||||
const clientMessages = client.send.mock.calls.map(([payload]) => JSON.parse(payload));
|
||||
expect(clientMessages).toContainEqual({
|
||||
t: 'appearance_changed_success',
|
||||
mapId: 'whale_port',
|
||||
skinId: 'classic_whale',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes a player from the public map while visiting a personal space', async () => {
|
||||
const leavingClient = Object.assign(createClient('socket_wangx'), {
|
||||
authenticated: true,
|
||||
worldReady: true,
|
||||
welcomed: true,
|
||||
userId: '1',
|
||||
username: 'wangx',
|
||||
currentMap: 'whale_port',
|
||||
}) as any;
|
||||
const observer = Object.assign(createClient('socket_test'), {
|
||||
authenticated: true,
|
||||
worldReady: true,
|
||||
welcomed: true,
|
||||
userId: '2',
|
||||
username: 'test',
|
||||
currentMap: 'whale_port',
|
||||
}) as any;
|
||||
(gateway as any).clients.set(leavingClient.id, leavingClient);
|
||||
(gateway as any).clients.set(observer.id, observer);
|
||||
(gateway as any).joinMapRoom(leavingClient.id, 'whale_port');
|
||||
(gateway as any).joinMapRoom(observer.id, 'whale_port');
|
||||
mockChatService.getSession!.mockResolvedValue({ position: { x: 650, y: 280 } } as any);
|
||||
mockChatService.updatePlayerPosition!.mockResolvedValue(true);
|
||||
mockChatService.getMapPlayerSnapshot!.mockResolvedValue([]);
|
||||
mockChatService.refreshPlayerAppearance!.mockResolvedValue({
|
||||
userId: '1',
|
||||
username: 'wangx',
|
||||
mapId: 'whale_port',
|
||||
x: 700,
|
||||
y: 300,
|
||||
appearance: { skinId: 'human_whale_directional_v2_8x4' },
|
||||
} as any);
|
||||
|
||||
await (gateway as any).routeMessage(leavingClient, {
|
||||
type: 'leave_world',
|
||||
sceneId: 'personal_space',
|
||||
});
|
||||
|
||||
expect(gateway.getMapPlayerCounts()).toEqual({ whale_port: 1 });
|
||||
expect(leavingClient.worldReady).toBe(false);
|
||||
expect(leavingClient.currentMap).toBe('private:1:personal_space');
|
||||
expect(mockChatService.updatePlayerPosition).toHaveBeenCalledWith({
|
||||
socketId: leavingClient.id,
|
||||
mapId: 'private:1:personal_space',
|
||||
x: 650,
|
||||
y: 280,
|
||||
});
|
||||
const observerMessages = observer.send.mock.calls.map(([payload]) => JSON.parse(payload));
|
||||
expect(observerMessages).toContainEqual(expect.objectContaining({
|
||||
t: 'player_left',
|
||||
userId: '1',
|
||||
mapId: 'whale_port',
|
||||
}));
|
||||
|
||||
leavingClient.send.mockClear();
|
||||
observer.send.mockClear();
|
||||
await (gateway as any).routeMessage(leavingClient, {
|
||||
type: 'world_ready',
|
||||
mapId: 'whale_port',
|
||||
x: 700,
|
||||
y: 300,
|
||||
});
|
||||
const returnMessages = [
|
||||
...leavingClient.send.mock.calls,
|
||||
...observer.send.mock.calls,
|
||||
].map(([payload]) => JSON.parse(payload));
|
||||
expect(returnMessages.some((message) => message.t === 'system_presence')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,9 +33,11 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import * as WebSocket from 'ws';
|
||||
import { ChatService } from '../../business/chat/chat.service';
|
||||
import { WorldNpcService } from '../../business/world_npc/world_npc.service';
|
||||
|
||||
/** WebSocket 服务器默认端口 */
|
||||
const DEFAULT_WEBSOCKET_PORT = 3001;
|
||||
const WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
/** 默认地图 ID */
|
||||
const DEFAULT_MAP_ID = 'whale_port';
|
||||
@@ -54,12 +56,18 @@ interface ExtendedWebSocket extends WebSocket {
|
||||
worldReady?: boolean;
|
||||
welcomed?: boolean;
|
||||
messageQueue?: Promise<void>;
|
||||
movementSequence?: number;
|
||||
guest?: boolean;
|
||||
lastNpcInteractionAt?: number;
|
||||
}
|
||||
|
||||
interface MapPositionMessage {
|
||||
mapId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
direction: 'down' | 'up' | 'right' | 'left';
|
||||
movementState: 'idle' | 'walk';
|
||||
sequence?: number;
|
||||
}
|
||||
|
||||
const WELCOME_RECONNECT_GRACE_MS = 15_000;
|
||||
@@ -101,8 +109,14 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
private clients = new Map<string, ExtendedWebSocket>();
|
||||
private mapRooms = new Map<string, Set<string>>();
|
||||
private lastWelcomeAtByUserId = new Map<string, number>();
|
||||
private heartbeatTimer?: NodeJS.Timeout;
|
||||
private npcActionTimer?: NodeJS.Timeout;
|
||||
private npcTickRunning = false;
|
||||
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
constructor(
|
||||
private readonly chatService: ChatService,
|
||||
private readonly worldNpcService: WorldNpcService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : DEFAULT_WEBSOCKET_PORT;
|
||||
@@ -128,6 +142,9 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
.then(() => this.handleRawMessage(ws, data))
|
||||
.catch((error) => this.logger.error(`消息处理失败: ${ws.id}`, error));
|
||||
});
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
ws.on('close', (code, reason) => this.handleClose(ws, code, reason));
|
||||
ws.on('error', (error) => this.handleError(ws, error));
|
||||
|
||||
@@ -140,10 +157,22 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
|
||||
// 设置网关引用到业务层
|
||||
this.chatService.setWebSocketGateway(this);
|
||||
this.heartbeatTimer = setInterval(() => this.checkClientHeartbeats(), WEBSOCKET_HEARTBEAT_INTERVAL_MS);
|
||||
this.npcActionTimer = setInterval(() => void this.tickNpcActions(), 1_000);
|
||||
this.heartbeatTimer.unref();
|
||||
this.npcActionTimer.unref();
|
||||
this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = undefined;
|
||||
}
|
||||
if (this.npcActionTimer) {
|
||||
clearInterval(this.npcActionTimer);
|
||||
this.npcActionTimer = undefined;
|
||||
}
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.logger.log('WebSocket服务器已关闭');
|
||||
@@ -174,12 +203,26 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
*/
|
||||
private async routeMessage(ws: ExtendedWebSocket, message: any) {
|
||||
const messageType = message.type || message.t;
|
||||
this.logger.log(`收到消息: ${ws.id}, 类型: ${messageType}`);
|
||||
if (messageType !== 'position' && messageType !== 'ping') {
|
||||
this.logger.log(`收到消息: ${ws.id}, 类型: ${messageType}`);
|
||||
}
|
||||
|
||||
if (ws.guest && !['ping', 'world_ready', 'logout', 'npc_session_end'].includes(messageType)) {
|
||||
this.sendMessage(ws, { t: 'error', code: 'GUEST_READ_ONLY', message: '游客模式只能参观' });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (messageType) {
|
||||
case 'ping':
|
||||
ws.isAlive = true;
|
||||
this.sendMessage(ws, { t: 'pong', timestamp: Date.now() });
|
||||
break;
|
||||
case 'login':
|
||||
await this.handleLogin(ws, message);
|
||||
break;
|
||||
case 'guest_login':
|
||||
await this.handleGuestLogin(ws);
|
||||
break;
|
||||
case 'logout':
|
||||
await this.handleLogout(ws);
|
||||
break;
|
||||
@@ -195,6 +238,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
case 'world_ready':
|
||||
await this.handleWorldReady(ws, message);
|
||||
break;
|
||||
case 'npc_interact':
|
||||
await this.handleNpcInteract(ws, message);
|
||||
break;
|
||||
case 'npc_session_end':
|
||||
await this.handleNpcSessionEnd(ws, message);
|
||||
break;
|
||||
case 'leave_world':
|
||||
await this.handleLeaveWorld(ws, message);
|
||||
break;
|
||||
@@ -225,6 +274,72 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGuestLogin(ws: ExtendedWebSocket): Promise<void> {
|
||||
ws.authenticated = true;
|
||||
ws.guest = true;
|
||||
ws.username = '游客';
|
||||
ws.currentMap = DEFAULT_MAP_ID;
|
||||
ws.worldReady = false;
|
||||
this.sendMessage(ws, { t: 'guest_login_success', currentMap: DEFAULT_MAP_ID, readOnly: true });
|
||||
}
|
||||
|
||||
private async handleNpcInteract(ws: ExtendedWebSocket, message: any): Promise<void> {
|
||||
if (!ws.authenticated || ws.guest || !ws.userId || !ws.worldReady) {
|
||||
this.sendMessage(ws, { t: 'npc_interaction_error', code: 'AUTH_REQUIRED', message: '请登录后再与NPC交流' });
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (ws.lastNpcInteractionAt && now - ws.lastNpcInteractionAt < 1_000) {
|
||||
this.sendMessage(ws, { t: 'npc_interaction_error', code: 'RATE_LIMITED', message: '请稍后再交流' });
|
||||
return;
|
||||
}
|
||||
const npcId = String(message.npcId || message.npc_id || '').trim();
|
||||
if (!npcId) {
|
||||
this.sendMessage(ws, { t: 'npc_interaction_error', code: 'NPC_REQUIRED', message: 'NPC不能为空' });
|
||||
return;
|
||||
}
|
||||
const session = await this.chatService.getSession(ws.id);
|
||||
if (!session) {
|
||||
this.sendMessage(ws, { t: 'npc_interaction_error', code: 'SESSION_EXPIRED', message: '会话已失效,请重新登录' });
|
||||
return;
|
||||
}
|
||||
ws.lastNpcInteractionAt = now;
|
||||
try {
|
||||
const result = await this.worldNpcService.interact({
|
||||
npcId,
|
||||
userId: String(ws.userId),
|
||||
username: String(ws.username || session.username || '居民'),
|
||||
mapId: String(session.currentMap || ws.currentMap || DEFAULT_MAP_ID),
|
||||
x: Number(session.position?.x),
|
||||
y: Number(session.position?.y),
|
||||
message: String(message.message || '').trim(),
|
||||
sessionId: String(message.sessionId || message.session_id || ''),
|
||||
});
|
||||
this.sendMessage(ws, { t: 'npc_interaction_success', ...result });
|
||||
this.broadcastToMap(session.currentMap, {
|
||||
t: 'npc_spoke',
|
||||
...result,
|
||||
targetUserId: ws.userId,
|
||||
targetUsername: ws.username,
|
||||
});
|
||||
} catch (error) {
|
||||
this.sendMessage(ws, {
|
||||
t: 'npc_interaction_error',
|
||||
code: 'INTERACTION_REJECTED',
|
||||
message: error instanceof Error ? error.message : 'NPC暂时无法回应',
|
||||
npcId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNpcSessionEnd(ws: ExtendedWebSocket, message: any): Promise<void> {
|
||||
if (!ws.authenticated || ws.guest || !ws.userId) return;
|
||||
await this.worldNpcService.endResidentSession(
|
||||
String(message.npcId || message.npc_id || ''), String(ws.userId), String(ws.username || ''),
|
||||
String(message.sessionId || message.session_id || ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录 - 协议转换后调用业务层
|
||||
*
|
||||
@@ -284,7 +399,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
}
|
||||
|
||||
try {
|
||||
await this.chatService.handlePlayerLogout(ws.id, 'manual');
|
||||
if (!ws.guest) await this.chatService.handlePlayerLogout(ws.id, 'manual');
|
||||
this.cleanupClient(ws);
|
||||
|
||||
this.sendMessage(ws, {
|
||||
@@ -326,18 +441,23 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
targetUsername: message.targetUsername || message.target_username,
|
||||
privateContext: message.privateContext || message.private_context,
|
||||
bubble: Boolean(message.bubble ?? message.showBubble ?? message.show_bubble),
|
||||
worldBulletin: message.worldBulletin === true || message.world_bulletin === true,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this.sendMessage(ws, {
|
||||
t: 'chat_sent',
|
||||
messageId: result.messageId,
|
||||
charged: result.charged,
|
||||
balance: result.balance,
|
||||
worldBulletin: message.worldBulletin === true || message.world_bulletin === true,
|
||||
message: '消息发送成功'
|
||||
});
|
||||
} else {
|
||||
this.sendMessage(ws, {
|
||||
t: 'chat_error',
|
||||
code: this.toClientErrorCode(result.error),
|
||||
worldBulletin: message.worldBulletin === true || message.world_bulletin === true,
|
||||
message: result.error || '消息发送失败'
|
||||
});
|
||||
}
|
||||
@@ -563,6 +683,10 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
this.sendError(ws, '位置消息无效');
|
||||
return;
|
||||
}
|
||||
const nextSequence = positionMessage.sequence ?? Number(ws.movementSequence ?? 0) + 1;
|
||||
if (ws.movementSequence !== undefined && nextSequence <= ws.movementSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const oldMapId = ws.currentMap || DEFAULT_MAP_ID;
|
||||
@@ -575,16 +699,20 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
ws.currentMap = positionMessage.mapId;
|
||||
}
|
||||
|
||||
await this.chatService.updatePlayerPosition({
|
||||
const updatedPresence = await this.chatService.updatePlayerPositionAndGetPresence({
|
||||
socketId: ws.id,
|
||||
x: positionMessage.x,
|
||||
y: positionMessage.y,
|
||||
mapId: positionMessage.mapId,
|
||||
direction: positionMessage.direction,
|
||||
movementState: positionMessage.movementState,
|
||||
sequence: nextSequence,
|
||||
});
|
||||
const updatedSession = await this.chatService.getSession(ws.id);
|
||||
const broadcastX = Number(updatedSession?.position?.x ?? positionMessage.x);
|
||||
const broadcastY = Number(updatedSession?.position?.y ?? positionMessage.y);
|
||||
const broadcastAppearance = updatedSession?.appearance;
|
||||
if (!updatedPresence) {
|
||||
this.sendMessage(ws, { type: 'error', code: 'SESSION_EXPIRED', message: '会话不存在,请重新登录' });
|
||||
return;
|
||||
}
|
||||
ws.movementSequence = nextSequence;
|
||||
|
||||
if (mapChanged) {
|
||||
this.broadcastToMap(oldMapId, {
|
||||
@@ -595,20 +723,24 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
}, ws.id);
|
||||
|
||||
await this.sendMapPlayersSnapshot(ws, positionMessage.mapId);
|
||||
this.sendMapNpcSnapshot(ws, positionMessage.mapId);
|
||||
}
|
||||
|
||||
const presencePayload = {
|
||||
t: 'position_update',
|
||||
userId: ws.userId,
|
||||
username: ws.username,
|
||||
x: broadcastX,
|
||||
y: broadcastY,
|
||||
x: updatedPresence.x,
|
||||
y: updatedPresence.y,
|
||||
mapId: positionMessage.mapId,
|
||||
skinId: broadcastAppearance?.skinId,
|
||||
avatarId: broadcastAppearance?.avatarId,
|
||||
skinAsset: broadcastAppearance?.skinAsset,
|
||||
cafeCompanion: updatedSession?.cafeCompanion ?? null,
|
||||
movementLocked: Boolean(updatedSession?.movementLocked),
|
||||
skinId: updatedPresence.skinId,
|
||||
avatarId: updatedPresence.avatarId,
|
||||
skinAsset: updatedPresence.skinAsset,
|
||||
cafeCompanion: updatedPresence.cafeCompanion ?? null,
|
||||
movementLocked: Boolean(updatedPresence.movementLocked),
|
||||
direction: updatedPresence.direction || positionMessage.direction,
|
||||
movementState: updatedPresence.movementState || positionMessage.movementState,
|
||||
sequence: Number(updatedPresence.sequence ?? nextSequence),
|
||||
};
|
||||
|
||||
this.broadcastToMap(positionMessage.mapId, mapChanged ? {
|
||||
@@ -635,6 +767,20 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
this.sendError(ws, '世界就绪消息无效');
|
||||
return;
|
||||
}
|
||||
if (ws.guest) {
|
||||
const guestMapId = DEFAULT_MAP_ID;
|
||||
if (ws.currentMap) this.leaveMapRoom(ws.id, ws.currentMap);
|
||||
ws.currentMap = guestMapId;
|
||||
ws.worldReady = true;
|
||||
this.joinMapRoom(ws.id, guestMapId);
|
||||
this.sendMessage(ws, { t: 'world_ready_success', mapId: guestMapId, readOnly: true });
|
||||
await this.sendMapPlayersSnapshot(ws, guestMapId);
|
||||
this.sendMapNpcSnapshot(ws, guestMapId);
|
||||
return;
|
||||
}
|
||||
const direction = this.normalizeDirection(message.direction);
|
||||
const movementState = this.normalizeMovementState(message.movementState ?? message.movement_state, 'idle');
|
||||
const sequence = this.normalizeSequence(message.sequence) ?? 0;
|
||||
|
||||
const wasWorldReady = Boolean(ws.worldReady);
|
||||
const oldMapId = ws.currentMap || DEFAULT_MAP_ID;
|
||||
@@ -650,7 +796,15 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
this.leaveMapRoom(ws.id, oldMapId);
|
||||
}
|
||||
|
||||
await this.chatService.updatePlayerPosition({ socketId: ws.id, mapId, x, y });
|
||||
await this.chatService.updatePlayerPosition({
|
||||
socketId: ws.id,
|
||||
mapId,
|
||||
x,
|
||||
y,
|
||||
direction,
|
||||
movementState,
|
||||
sequence,
|
||||
});
|
||||
const refreshedPresence = await this.chatService.refreshPlayerAppearance(ws.id);
|
||||
if (!refreshedPresence) {
|
||||
this.sendError(ws, '外观刷新失败');
|
||||
@@ -659,10 +813,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
|
||||
ws.currentMap = mapId;
|
||||
ws.worldReady = true;
|
||||
ws.movementSequence = sequence;
|
||||
this.joinMapRoom(ws.id, mapId);
|
||||
|
||||
this.sendMessage(ws, { t: 'world_ready_success', mapId });
|
||||
await this.sendMapPlayersSnapshot(ws, mapId);
|
||||
this.sendMapNpcSnapshot(ws, mapId);
|
||||
|
||||
const appearance = refreshedPresence.appearance;
|
||||
this.broadcastToMap(mapId, {
|
||||
@@ -677,6 +833,9 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
skinAsset: appearance?.skinAsset,
|
||||
cafeCompanion: refreshedPresence.cafeCompanion ?? null,
|
||||
movementLocked: Boolean(refreshedPresence.movementLocked),
|
||||
direction: refreshedPresence.direction || direction,
|
||||
movementState: refreshedPresence.movementState || movementState,
|
||||
sequence: Number(refreshedPresence.sequence ?? sequence),
|
||||
}, ws.id);
|
||||
|
||||
if (!wasWorldReady && !ws.welcomed) {
|
||||
@@ -746,6 +905,11 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
t: 'appearance_changed',
|
||||
...presence,
|
||||
}, ws.id);
|
||||
this.sendMessage(ws, {
|
||||
t: 'appearance_changed_success',
|
||||
mapId: presence.mapId,
|
||||
skinId: presence.skinId ?? presence.appearance?.skinId ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -825,6 +989,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
}, ws.id);
|
||||
|
||||
await this.sendMapPlayersSnapshot(ws, newMapId);
|
||||
this.sendMapNpcSnapshot(ws, newMapId);
|
||||
this.logger.log(`用户切换地图: ${ws.username} (${oldMapId} -> ${newMapId})`);
|
||||
|
||||
} catch (error) {
|
||||
@@ -933,6 +1098,47 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
this.sendMessage(ws, { type: 'error', code: this.toClientErrorCode(message), message });
|
||||
}
|
||||
|
||||
private checkClientHeartbeats(): void {
|
||||
this.clients.forEach((client) => {
|
||||
if (client.isAlive === false) {
|
||||
this.logger.warn(`WebSocket心跳超时: ${client.id}`);
|
||||
client.terminate();
|
||||
return;
|
||||
}
|
||||
client.isAlive = false;
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.ping();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async tickNpcActions(): Promise<void> {
|
||||
if (this.npcTickRunning) return;
|
||||
this.npcTickRunning = true;
|
||||
try {
|
||||
const result = await this.worldNpcService.tick();
|
||||
result.completed.forEach((completed) => this.broadcastToMap(completed.mapId, {
|
||||
t: 'npc_action_completed',
|
||||
...completed,
|
||||
x: completed.action.toX,
|
||||
y: completed.action.toY,
|
||||
}));
|
||||
result.started.forEach((started) => this.broadcastToMap(started.mapId, {
|
||||
t: 'npc_action_started',
|
||||
...started,
|
||||
}));
|
||||
result.conversations.forEach((conversation) => this.broadcastToMap(conversation.mapId, {
|
||||
t: 'npc_conversation',
|
||||
...conversation,
|
||||
}));
|
||||
result.changedMaps.forEach((mapId) => this.broadcastMapNpcSnapshot(mapId));
|
||||
} catch (error) {
|
||||
this.logger.error(`NPC 运行时 tick 失败: ${error instanceof Error ? error.message : error}`);
|
||||
} finally {
|
||||
this.npcTickRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private toClientErrorCode(message?: string): string {
|
||||
const normalizedMessage = String(message || '');
|
||||
if (normalizedMessage.includes('会话不存在') || normalizedMessage.includes('重新登录')) {
|
||||
@@ -941,6 +1147,12 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
if (normalizedMessage.includes('请先登录') || normalizedMessage.includes('Token')) {
|
||||
return 'AUTH_FAILED';
|
||||
}
|
||||
if (normalizedMessage.includes('余额不足')) {
|
||||
return 'INSUFFICIENT_BALANCE';
|
||||
}
|
||||
if (normalizedMessage.includes('钱包服务')) {
|
||||
return 'WALLET_UNAVAILABLE';
|
||||
}
|
||||
return 'CHAT_ERROR';
|
||||
}
|
||||
|
||||
@@ -972,9 +1184,28 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
mapId,
|
||||
x,
|
||||
y,
|
||||
direction: this.normalizeDirection(message.direction),
|
||||
movementState: this.normalizeMovementState(message.movementState ?? message.movement_state, 'walk'),
|
||||
sequence: this.normalizeSequence(message.sequence),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeDirection(value: unknown): 'down' | 'up' | 'right' | 'left' {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'up' || normalized === 'right' || normalized === 'left' ? normalized : 'down';
|
||||
}
|
||||
|
||||
private normalizeMovementState(value: unknown, fallback: 'idle' | 'walk'): 'idle' | 'walk' {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'idle' || normalized === 'walk' ? normalized : fallback;
|
||||
}
|
||||
|
||||
private normalizeSequence(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const sequence = Number(value);
|
||||
return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : undefined;
|
||||
}
|
||||
|
||||
private async sendMapPlayersSnapshot(ws: ExtendedWebSocket, mapId?: string): Promise<void> {
|
||||
const normalizedMapId = String(mapId || DEFAULT_MAP_ID).trim();
|
||||
if (!normalizedMapId) return;
|
||||
@@ -993,9 +1224,25 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
});
|
||||
}
|
||||
|
||||
private sendMapNpcSnapshot(ws: ExtendedWebSocket, mapId?: string): void {
|
||||
const normalizedMapId = String(mapId || DEFAULT_MAP_ID).trim();
|
||||
if (!normalizedMapId) return;
|
||||
|
||||
const snapshot = this.worldNpcService.getMapSnapshot(normalizedMapId);
|
||||
this.sendMessage(ws, {
|
||||
t: 'npc_snapshot',
|
||||
...snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastMapNpcSnapshot(mapId: string): void {
|
||||
const snapshot = this.worldNpcService.getMapSnapshot(mapId);
|
||||
this.broadcastToMap(mapId, { t: 'npc_snapshot', ...snapshot });
|
||||
}
|
||||
|
||||
private async cleanupClient(ws: ExtendedWebSocket, reason: 'manual' | 'timeout' | 'disconnect' = 'disconnect') {
|
||||
try {
|
||||
if (ws.authenticated && ws.worldReady && ws.currentMap) {
|
||||
if (ws.authenticated && !ws.guest && ws.worldReady && ws.currentMap) {
|
||||
this.broadcastToMap(ws.currentMap, {
|
||||
t: 'player_left',
|
||||
userId: ws.userId,
|
||||
@@ -1003,7 +1250,7 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
|
||||
mapId: ws.currentMap,
|
||||
}, ws.id);
|
||||
}
|
||||
if (ws.authenticated && ws.id) {
|
||||
if (ws.authenticated && !ws.guest && ws.id) {
|
||||
await this.chatService.handlePlayerLogout(ws.id, reason);
|
||||
}
|
||||
if (ws.currentMap) {
|
||||
|
||||
Reference in New Issue
Block a user