feat: integrate invitation access, world NPCs, and deployment

This commit is contained in:
2026-09-08 22:22:38 +08:00
parent 2a3125075f
commit 513a3eba31
75 changed files with 9566 additions and 1070 deletions

View File

@@ -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
*/

View File

@@ -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);

View 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);
});
});
});

View File

@@ -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);

View File

@@ -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'],

View File

@@ -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,

View 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);
});
});
});

View File

@@ -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) {