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

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