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

@@ -40,6 +40,9 @@ import { LoginCoreService } from '../../core/login_core/login_core.service';
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
import { AccountProfileService } from '../auth/account_profile.service';
import { EconomyService } from '../player/economy.service';
const WORLD_BULLETIN_COST = 100;
// ========== 接口定义 ==========
@@ -63,6 +66,8 @@ export interface ChatMessageRequest {
privateContext?: string;
/** 是否同步显示角色气泡 */
bubble?: boolean;
/** 是否发布收费的世界公告 */
worldBulletin?: boolean;
}
/**
@@ -75,6 +80,10 @@ export interface ChatMessageResponse {
messageId?: string;
/** 错误信息(失败时返回) */
error?: string;
/** 本次服务端实际扣费 */
charged?: number;
/** 扣费后的实时余额 */
balance?: number;
}
/**
@@ -119,6 +128,12 @@ export interface PositionUpdateRequest {
mapId: string;
/** 外观同步信息 */
appearance?: IPlayerAppearance;
/** 面向方向 */
direction?: 'down' | 'up' | 'right' | 'left';
/** 移动动画状态 */
movementState?: 'idle' | 'walk';
/** 当前连接内的移动消息序号 */
sequence?: number;
}
export interface PlayerPresenceStateUpdateRequest {
@@ -150,10 +165,18 @@ export interface MapPlayerSnapshotItem {
skinId?: string;
/** 头像ID兼容前端实时位置协议 */
avatarId?: string;
/** 自定义皮肤资源(兼容前端实时位置协议) */
skinAsset?: Record<string, any>;
/** 咖啡店陪伴服务状态 */
cafeCompanion?: ICafeCompanionPresence | null;
/** 是否锁定移动 */
movementLocked?: boolean;
/** 面向方向 */
direction?: 'down' | 'up' | 'right' | 'left';
/** 移动动画状态 */
movementState?: 'idle' | 'walk';
/** 当前连接内的移动消息序号 */
sequence?: number;
}
/**
@@ -196,6 +219,8 @@ interface GameChatMessage {
toUsername?: string;
/** 私聊来源上下文whisper / friends */
privateContext?: string;
/** 收费世界公告标记 */
worldBulletin?: boolean;
}
/**
@@ -262,6 +287,7 @@ export class ChatService {
@Inject('ZulipAccountsService')
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
private readonly accountProfileService: AccountProfileService,
private readonly economyService: EconomyService,
) {
this.logger.log('ChatService初始化完成');
}
@@ -382,7 +408,10 @@ export class ChatService {
return { success: false, error: '会话不存在,请重新登录' };
}
const normalizedScope = this.normalizeChatScope(request.scope);
// 世界公告的频道和价格均由服务端决定,不信任客户端传值。
const normalizedScope = request.worldBulletin
? 'global'
: this.normalizeChatScope(request.scope);
if (normalizedScope === 'private' && !request.targetUserId?.trim()) {
return { success: false, error: '请选择悄悄话对象' };
@@ -410,6 +439,33 @@ export class ChatService {
const messageContent = validationResult.filteredContent || request.content;
const messageId = `game_${Date.now()}_${session.userId}`;
let chargedBalance: number | undefined;
if (request.worldBulletin) {
if (!/^\d+$/.test(session.userId)) {
return { success: false, error: '钱包服务暂不可用' };
}
try {
const wallet = await this.economyService.spend(
BigInt(session.userId),
WORLD_BULLETIN_COST,
'world_bulletin',
messageId,
'发布世界公告',
);
chargedBalance = wallet.balance;
} catch (chargeError) {
const chargeMessage = (chargeError as Error).message || '';
if (chargeMessage.includes('余额不足')) {
return {
success: false,
error: `鲸币余额不足,发布世界公告需要 ${WORLD_BULLETIN_COST} 鲸币`,
};
}
this.logger.error('世界公告扣费失败', { error: chargeMessage, userId: session.userId });
return { success: false, error: '钱包服务暂不可用' };
}
}
// 5. 🚀 立即广播给游戏内玩家根据scope决定广播范围
const gameMessage: GameChatMessage = {
@@ -422,6 +478,7 @@ export class ChatService {
messageId,
mapId: targetMapId,
scope: normalizedScope,
worldBulletin: Boolean(request.worldBulletin),
};
if (normalizedScope === 'private') {
@@ -432,9 +489,26 @@ export class ChatService {
// local: 当前地图global: 所有在线玩家private: 仅发送者与目标玩家。
try {
await this.dispatchGameChatMessage(gameMessage, request.socketId);
await this.dispatchGameChatMessage(gameMessage, request.socketId, Boolean(request.worldBulletin));
this.recordChatHistory(gameMessage);
} catch (dispatchError) {
if (request.worldBulletin) {
try {
await this.economyService.earn(
BigInt(session.userId),
WORLD_BULLETIN_COST,
'world_bulletin_refund',
messageId,
'世界公告发送失败退款',
);
} catch (refundError) {
this.logger.error('世界公告发送失败且退款失败', {
messageId,
userId: session.userId,
error: (refundError as Error).message,
});
}
}
const message = (dispatchError as Error).message || '消息发送失败';
return { success: false, error: message };
}
@@ -451,7 +525,12 @@ export class ChatService {
duration: Date.now() - startTime,
});
return { success: true, messageId };
return {
success: true,
messageId,
charged: request.worldBulletin ? WORLD_BULLETIN_COST : undefined,
balance: chargedBalance,
};
} catch (error) {
this.logger.error('聊天消息发送失败', { error: (error as Error).message });
@@ -477,6 +556,9 @@ export class ChatService {
request.y,
{
appearance: request.appearance,
direction: request.direction,
movementState: request.movementState,
sequence: request.sequence,
},
);
} catch (error) {
@@ -485,6 +567,30 @@ export class ChatService {
}
}
async updatePlayerPositionAndGetPresence(request: PositionUpdateRequest): Promise<MapPlayerSnapshotItem | null> {
try {
if (!request.socketId?.trim() || !request.mapId?.trim()) {
return null;
}
const presence = await this.sessionService.updatePlayerPositionWithPresence(
request.socketId,
request.mapId,
request.x,
request.y,
{
appearance: request.appearance,
direction: request.direction,
movementState: request.movementState,
sequence: request.sequence,
},
);
return presence ? this.toMapPlayerSnapshotItem(presence) : null;
} catch (error) {
this.logger.error('更新位置失败', { error: (error as Error).message });
return null;
}
}
async updatePlayerPresenceState(
request: PlayerPresenceStateUpdateRequest,
): Promise<{ success: boolean; presence?: MapPlayerSnapshotItem; socketId?: string; error?: string }> {
@@ -555,6 +661,9 @@ export class ChatService {
appearance: updatedSession.appearance,
cafeCompanion: updatedSession.cafeCompanion ?? null,
movementLocked: Boolean(updatedSession.movementLocked),
direction: updatedSession.direction || 'down',
movementState: updatedSession.movementState || 'idle',
sequence: Number(updatedSession.movementSequence ?? 0),
});
}
@@ -866,7 +975,7 @@ export class ChatService {
const clientInstance = await this.zulipClientPool.createUserClient(userId, {
username: zulipEmail,
apiKey: apiKey,
realm: process.env.ZULIP_SERVER_URL || 'https://zulip.xinghangee.icu/',
realm: process.env.ZULIP_SERVER_URL || 'https://zulip.novamailio.com/',
});
this.logger.log('Zulip客户端创建成功', {
@@ -993,9 +1102,13 @@ export class ChatService {
return 'local';
}
private async dispatchGameChatMessage(message: GameChatMessage, senderSocketId: string): Promise<void> {
private async dispatchGameChatMessage(
message: GameChatMessage,
senderSocketId: string,
includeSender = false,
): Promise<void> {
if (message.scope === 'global') {
this.broadcastToAllGamePlayers(message, senderSocketId);
this.broadcastToAllGamePlayers(message, includeSender ? undefined : senderSocketId);
return;
}
@@ -1063,8 +1176,12 @@ export class ChatService {
appearance: player.appearance,
skinId: player.appearance?.skinId,
avatarId: player.appearance?.avatarId,
skinAsset: player.appearance?.skinAsset,
cafeCompanion: player.cafeCompanion ?? null,
movementLocked: Boolean(player.movementLocked),
direction: player.direction || 'down',
movementState: player.movementState || 'idle',
sequence: Number(player.sequence ?? 0),
};
}
@@ -1086,6 +1203,9 @@ export class ChatService {
avatarId: presence.avatarId,
cafeCompanion: presence.cafeCompanion ?? null,
movementLocked: Boolean(presence.movementLocked),
direction: presence.direction || 'down',
movementState: presence.movementState || 'idle',
sequence: Number(presence.sequence ?? 0),
});
}