diff --git a/src/business/auth/account_profile.service.ts b/src/business/auth/account_profile.service.ts index 12b36c5..589f1de 100644 --- a/src/business/auth/account_profile.service.ts +++ b/src/business/auth/account_profile.service.ts @@ -56,7 +56,8 @@ export interface AccountProfilePayload { }; } -export type AccountSettings = Record; +export type RoomVisitPolicy = 'friends' | 'public' | 'closed'; +export type AccountSettings = Record; export interface UpdateAccountProfileRequest { skin_id?: string; @@ -101,6 +102,7 @@ const DEFAULT_ACCOUNT_SETTINGS: AccountSettings = { allow_nearby_private: true, allow_nearby_friend_requests: true, allow_nearby_profile: true, + room_visit_policy: 'friends', mute_ui_sfx: false, }; const ACCOUNT_SETTING_NUMBER_KEYS = new Set(['master_volume', 'music_volume', 'effects_volume', 'ui_scale']); @@ -118,6 +120,7 @@ const ACCOUNT_SETTING_BOOLEAN_KEYS = new Set([ 'allow_nearby_profile', 'mute_ui_sfx', ]); +const ROOM_VISIT_POLICIES = new Set(['friends', 'public', 'closed']); export interface AccountSkinAsset { id: string; @@ -669,6 +672,8 @@ export class AccountProfileService { } } else if (ACCOUNT_SETTING_BOOLEAN_KEYS.has(key)) { sanitized[key] = typeof value === 'boolean' ? value : value === 'true' || value === 1 || value === '1'; + } else if (key === 'room_visit_policy' && typeof value === 'string' && ROOM_VISIT_POLICIES.has(value as RoomVisitPolicy)) { + sanitized[key] = value as RoomVisitPolicy; } } return sanitized; diff --git a/src/business/mall/mall_catalog.ts b/src/business/mall/mall_catalog.ts index 225a8cf..ba3cb18 100644 --- a/src/business/mall/mall_catalog.ts +++ b/src/business/mall/mall_catalog.ts @@ -1,3 +1,5 @@ +import { ROOM_DECOR_DEFINITIONS } from '../room_decor/room_decor_catalog'; + export type MallItemType = 'skin' | 'room_decor'; export interface MallCatalogItem { @@ -23,6 +25,19 @@ export const MALL_CATEGORIES = [ { id: 'limited', label: '限时', icon: 'limited' }, ]; +const ROOM_DECOR_MALL_ITEMS: MallCatalogItem[] = ROOM_DECOR_DEFINITIONS.map((definition) => ({ + itemId: definition.item_id, + itemType: 'room_decor', + decorId: definition.decor_id, + icon: definition.icon, + name: definition.name, + category: definition.shop.category, + description: definition.shop.description, + price: definition.shop.price, + tags: definition.shop.tags, + sortOrder: definition.shop.sort_order, +})); + export const MALL_ITEMS: MallCatalogItem[] = [ { itemId: 'skin_classic_whale', @@ -81,126 +96,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [ tags: ['可预览', '永久', '皮肤'], sortOrder: 50, }, - { - itemId: 'decor_whale_floor_rug', - itemType: 'room_decor', - decorId: 'whale_floor_rug', - icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png', - name: '鲸浪地毯', - category: 'space', - description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。', - price: 260, - tags: ['房间家具', '可拖拽', '地面'], - sortOrder: 110, - }, - { - itemId: 'decor_whale_memory_board', - itemType: 'room_decor', - decorId: 'whale_memory_board', - icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png', - name: '鲸语记忆板', - category: 'space', - description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。', - price: 220, - tags: ['房间家具', '可拖拽', '挂件'], - sortOrder: 120, - }, - { - itemId: 'decor_whale_tail_lamp', - itemType: 'room_decor', - decorId: 'whale_tail_lamp', - icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png', - name: '鲸尾暖灯', - category: 'space', - description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。', - price: 360, - tags: ['房间家具', '可拖拽', '灯具'], - sortOrder: 130, - }, - { - itemId: 'decor_boat_cabin_bed', - itemType: 'room_decor', - decorId: 'boat_cabin_bed', - icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png', - name: '船舱小床', - category: 'space', - description: '白木船舱造型的小床,适合放在个人房间地面区域。', - price: 520, - tags: ['房间家具', '可拖拽', '床'], - sortOrder: 140, - }, - { - itemId: 'decor_low_wave_bed', - itemType: 'room_decor', - decorId: 'low_wave_bed', - icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png', - name: '海浪低床', - category: 'space', - description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。', - price: 500, - tags: ['房间家具', '可拖拽', '床'], - sortOrder: 150, - }, - { - itemId: 'decor_whale_tail_headboard_bed', - itemType: 'room_decor', - decorId: 'whale_tail_headboard_bed', - icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png', - name: '鲸尾床头床', - category: 'space', - description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。', - price: 580, - tags: ['房间家具', '可拖拽', '床'], - sortOrder: 180, - }, - { - itemId: 'decor_dev_whale_bookshelf', - itemType: 'room_decor', - decorId: 'dev_whale_bookshelf', - icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png', - name: '程序员鲸书架', - category: 'space', - description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。', - price: 620, - tags: ['房间家具', '可拖拽', '书架'], - sortOrder: 190, - }, - { - itemId: 'decor_datawhale_bug_feature_badge', - itemType: 'room_decor', - decorId: 'datawhale_bug_feature_badge', - icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png', - name: 'BUG特性徽章', - category: 'space', - description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。', - price: 120, - tags: ['房间家具', '可拖拽', '徽章'], - sortOrder: 200, - }, - { - itemId: 'decor_datawhale_buddhist_learning_badge', - itemType: 'room_decor', - decorId: 'datawhale_buddhist_learning_badge', - icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png', - name: '佛系学习徽章', - category: 'space', - description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。', - price: 140, - tags: ['房间家具', '可拖拽', '徽章'], - sortOrder: 210, - }, - { - itemId: 'decor_datawhale_ok_working_badge', - itemType: 'room_decor', - decorId: 'datawhale_ok_working_badge', - icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png', - name: '已经在做徽章', - category: 'space', - description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。', - price: 120, - tags: ['房间家具', '可拖拽', '徽章'], - sortOrder: 220, - }, + ...ROOM_DECOR_MALL_ITEMS, ]; export const MALL_SKIN_ITEMS = MALL_ITEMS.filter((item) => item.itemType === 'skin' && item.skinId); diff --git a/src/business/player/player.types.ts b/src/business/player/player.types.ts index 8e34300..d2b0898 100644 --- a/src/business/player/player.types.ts +++ b/src/business/player/player.types.ts @@ -48,5 +48,5 @@ export interface PlayerSnapshotPayload { owned_skin_ids: string[]; owned_skins: unknown[]; }; - settings: Record; + settings: Record; } diff --git a/src/business/room_decor/dto/save_room_decor_placement.dto.ts b/src/business/room_decor/dto/save_room_decor_placement.dto.ts index dadfb3d..54e5883 100644 --- a/src/business/room_decor/dto/save_room_decor_placement.dto.ts +++ b/src/business/room_decor/dto/save_room_decor_placement.dto.ts @@ -1,10 +1,11 @@ -import { IsBoolean, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator'; export class SaveRoomDecorPlacementDto { + @IsOptional() @IsString({ message: '摆件ID必须是字符串' }) @Length(1, 100, { message: '摆件ID长度需在1-100字符之间' }) @Matches(/^[A-Za-z0-9_:-]+$/, { message: '摆件ID格式不正确' }) - decor_id!: string; + decor_id?: string; @IsBoolean({ message: '摆放状态必须是布尔值' }) placed!: boolean; @@ -27,6 +28,13 @@ export class SaveRoomDecorPlacementDto { @Max(4, { message: '缩放不能太大' }) scale?: number; + @IsOptional() + @IsNumber({}, { message: '旋转角度必须是数字' }) + @IsInt({ message: '旋转角度必须是整数' }) + @Min(0, { message: '旋转角度超出范围' }) + @Max(270, { message: '旋转角度超出范围' }) + rotation_degrees?: number; + @IsOptional() @IsNumber({}, { message: '层级必须是数字' }) @Min(-1000, { message: '层级超出范围' }) diff --git a/src/business/room_decor/room_decor.controller.ts b/src/business/room_decor/room_decor.controller.ts index 2c65076..23b6232 100644 --- a/src/business/room_decor/room_decor.controller.ts +++ b/src/business/room_decor/room_decor.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, HttpStatus, Param, Put, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, HttpStatus, Param, Post, Put, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { JwtPayload } from '../../core/login_core/login_core.service'; @@ -9,7 +9,7 @@ import { RoomDecorService } from './room_decor.service'; @ApiTags('room-decor') @ApiBearerAuth() -@Controller('rooms/me/decor-placements') +@Controller('rooms') @UseGuards(JwtAuthGuard) export class RoomDecorController { constructor(private readonly roomDecorService: RoomDecorService) {} @@ -22,7 +22,7 @@ export class RoomDecorController { status: 200, description: '房间家具背包获取成功', }) - @Get() + @Get('me/decor-placements') async getInventory(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise { const data = await this.roomDecorService.getInventory(BigInt(user.sub)); res.status(HttpStatus.OK).json({ @@ -32,6 +32,40 @@ export class RoomDecorController { }); } + @ApiOperation({ + summary: '参观其他玩家的房间布局', + description: '按房主访问策略返回只读布局;拉黑关系始终拒绝访问。', + }) + @SwaggerApiResponse({ status: 200, description: '只读房间布局获取成功' }) + @Get(':ownerUserId/decor-placements') + async getRoomView( + @CurrentUser() user: JwtPayload, + @Param('ownerUserId') ownerUserId: string, + @Res() res: Response, + ): Promise { + const data = await this.roomDecorService.getRoomView(BigInt(user.sub), this.parseUserId(ownerUserId)); + res.status(HttpStatus.OK).json({ + success: true, + data, + message: '房间布局获取成功', + }); + } + + @ApiOperation({ + summary: '收回当前账号所有已摆放家具', + description: '仅清空布局,不删除已拥有家具资产。', + }) + @SwaggerApiResponse({ status: 200, description: '房间布局已重置' }) + @Post('me/decor-placements/reset') + async resetPlacements(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise { + const data = await this.roomDecorService.resetPlacements(BigInt(user.sub)); + res.status(HttpStatus.OK).json({ + success: true, + data, + message: '房间布局已重置,家具已收回背包', + }); + } + @ApiOperation({ summary: '保存房间家具摆放', description: '保存当前账号某个家具的摆放状态、位置、缩放和层级。', @@ -40,7 +74,7 @@ export class RoomDecorController { status: 200, description: '家具摆放保存成功', }) - @Put(':decorId') + @Put('me/decor-placements/:decorId') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) async savePlacement( @CurrentUser() user: JwtPayload, @@ -58,4 +92,11 @@ export class RoomDecorController { message: '家具摆放保存成功', }); } + + private parseUserId(value: string): bigint { + if (!/^\d+$/.test(value)) { + throw new BadRequestException('房主用户ID格式不正确'); + } + return BigInt(value); + } } diff --git a/src/business/room_decor/room_decor.service.ts b/src/business/room_decor/room_decor.service.ts index a2fde74..4738f80 100644 --- a/src/business/room_decor/room_decor.service.ts +++ b/src/business/room_decor/room_decor.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { InventoryService } from '../player/inventory.service'; +import { SocialService } from '../social/social.service'; import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto'; import { ROOM_DECOR_BED_DEFAULT_SCALE, @@ -8,6 +9,7 @@ import { ROOM_DECOR_LEGACY_BED_MAX_SCALE, ROOM_DECOR_LEGACY_WALL_DECOR_SCALES, ROOM_DECOR_ROOM_SCALE, + RoomDecorDefinition, findRoomDecorDefinition, } from './room_decor_catalog'; @@ -17,6 +19,7 @@ interface UserRoomDecorRow { position_x: number | null; position_y: number | null; scale: number; + rotation_degrees?: number; z_index: number; } @@ -29,6 +32,7 @@ interface RoomDecorPayloadPlacement { position_x: number | null; position_y: number | null; scale: number; + rotation_degrees: number; } @Injectable() @@ -36,6 +40,7 @@ export class RoomDecorService { constructor( @Inject('IRoomDecorPlacementsService') private readonly roomDecorPlacementsService: IRoomDecorPlacementsService, private readonly inventoryService: InventoryService, + private readonly socialService: SocialService, ) {} async getInventory(userId: bigint) { @@ -53,6 +58,7 @@ export class RoomDecorService { position_x: definition?.default_position.x ?? null, position_y: definition?.default_position.y ?? null, scale: definition?.default_scale ?? 1, + rotation_degrees: definition?.default_rotation_degrees ?? 0, z_index: definition?.default_z_index ?? 0, }; }); @@ -65,21 +71,63 @@ export class RoomDecorService { } async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto) { - const definition = findRoomDecorDefinition(placement.decor_id); + const decorId = placement.decor_id?.trim() || ''; + const definition = findRoomDecorDefinition(decorId); if (!definition) { throw new BadRequestException('摆件不存在或暂未开放'); } - if (!(await this.inventoryService.hasAsset(userId, 'room_decor', placement.decor_id))) { + if (!(await this.inventoryService.hasAsset(userId, 'room_decor', decorId))) { throw new BadRequestException('尚未拥有该房间摆件'); } + const rotationDegrees = placement.rotation_degrees ?? definition.default_rotation_degrees; + if (!definition.rotation_steps.includes(rotationDegrees)) { + throw new BadRequestException('该摆件不支持此旋转角度'); + } const row = await this.roomDecorPlacementsService.savePlacement(userId, { ...placement, + decor_id: decorId, + position_x: this.snapToGrid(placement.position_x, definition.grid_size), + position_y: this.snapToGrid(placement.position_y, definition.grid_size), scale: placement.scale ?? definition.default_scale, + rotation_degrees: rotationDegrees, z_index: placement.z_index ?? definition.default_z_index, }); return this.toPayload(row); } + async getRoomView(viewerId: bigint, ownerId: bigint) { + const owner = await this.socialService.getRoomOwnerProfile(viewerId, ownerId); + const [inventory, placements] = await Promise.all([ + this.inventoryService.listInventory(ownerId, 'room_decor'), + this.roomDecorPlacementsService.listPlacements(ownerId), + ]); + const ownedDecorIds = new Set(inventory.room_decor_ids); + return { + owner, + read_only: true, + items: placements + .filter((row) => row.placed && ownedDecorIds.has(row.decor_id) && Boolean(findRoomDecorDefinition(row.decor_id))) + .map((row) => this.toPayload(row)), + definitions: ROOM_DECOR_DEFINITIONS, + }; + } + + async resetPlacements(userId: bigint) { + const inventory = await this.inventoryService.listInventory(userId, 'room_decor'); + await Promise.all(inventory.room_decor_ids.map(async (decorId) => { + const definition = findRoomDecorDefinition(decorId); + if (!definition) return; + await this.roomDecorPlacementsService.savePlacement(userId, { + decor_id: decorId, + placed: false, + scale: definition.default_scale, + rotation_degrees: definition.default_rotation_degrees, + z_index: definition.default_z_index, + }); + })); + return this.getInventory(userId); + } + private toPayload(row: UserRoomDecorRow) { const definition = findRoomDecorDefinition(row.decor_id); const placement = this.normalizedPlacement(row, definition); @@ -93,26 +141,34 @@ export class RoomDecorService { position_x: placement.position_x, position_y: placement.position_y, scale: placement.scale, + rotation_degrees: placement.rotation_degrees, z_index: row.z_index ?? definition?.default_z_index ?? 0, + category: definition?.category ?? 'floor', + placement_surface: definition?.placement_surface ?? 'floor', + grid_size: definition?.grid_size ?? 16, + rotation_steps: definition?.rotation_steps ?? [0], + stackable: definition?.stackable ?? false, default_position: definition?.default_position ?? { x: 0, y: 0 }, default_scale: definition?.default_scale ?? 1, + default_rotation_degrees: definition?.default_rotation_degrees ?? 0, default_z_index: definition?.default_z_index ?? 0, }; } private normalizedPlacement( row: UserRoomDecorRow, - definition?: { default_scale: number; default_position: { x: number; y: number } }, + definition?: RoomDecorDefinition, ): RoomDecorPayloadPlacement { const usesLegacyPlacement = this.usesLegacyPlacement(row); return { position_x: this.normalizedPositionValue(row.position_x, definition?.default_position.x ?? 0, usesLegacyPlacement), position_y: this.normalizedPositionValue(row.position_y, definition?.default_position.y ?? 0, usesLegacyPlacement), scale: this.normalizedScale(row, definition), + rotation_degrees: this.normalizedRotation(row, definition), }; } - private normalizedScale(row: UserRoomDecorRow, definition?: { default_scale: number }) { + private normalizedScale(row: UserRoomDecorRow, definition?: Pick) { const scale = row.scale ?? definition?.default_scale ?? 1; if (!row.placed && definition) { return definition.default_scale; @@ -146,6 +202,16 @@ export class RoomDecorService { return usesLegacyPlacement ? Math.round(value * ROOM_DECOR_ROOM_SCALE) : value; } + private normalizedRotation(row: UserRoomDecorRow, definition?: RoomDecorDefinition): number { + const rotation = row.rotation_degrees ?? definition?.default_rotation_degrees ?? 0; + return definition?.rotation_steps.includes(rotation) ? rotation : definition?.default_rotation_degrees ?? 0; + } + + private snapToGrid(value: number | undefined, gridSize: number): number | undefined { + if (value === undefined) return undefined; + return Math.round(value / gridSize) * gridSize; + } + private usesLegacyPlacement(row: UserRoomDecorRow) { const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id]; if (!legacy) { diff --git a/src/business/room_decor/room_decor_catalog.ts b/src/business/room_decor/room_decor_catalog.ts index e63da41..739311a 100644 --- a/src/business/room_decor/room_decor_catalog.ts +++ b/src/business/room_decor/room_decor_catalog.ts @@ -2,14 +2,27 @@ export interface RoomDecorDefinition { decor_id: string; name: string; item_id: string; + category: 'floor' | 'rug' | 'wall'; + placement_surface: 'floor' | 'wall'; + grid_size: number; + rotation_steps: number[]; + stackable: boolean; icon: string; texture?: string; default_scale: number; + default_rotation_degrees: number; default_position: { x: number; y: number; }; default_z_index: number; + shop: { + category: 'space'; + description: string; + price: number; + tags: string[]; + sort_order: number; + }; collision_size?: { x: number; y: number; @@ -84,30 +97,51 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [ decor_id: 'whale_floor_rug', item_id: 'decor_whale_floor_rug', name: '鲸浪地毯', + category: 'rug', + placement_surface: 'floor', + grid_size: 16, + rotation_steps: [0], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png', default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE, + default_rotation_degrees: 0, default_position: { x: 0, y: 161 }, default_z_index: -8, + shop: { category: 'space', description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。', price: 260, tags: ['房间家具', '可拖拽', '地面'], sort_order: 110 }, }, { decor_id: 'whale_memory_board', item_id: 'decor_whale_memory_board', name: '鲸语记忆板', + category: 'wall', + placement_surface: 'wall', + grid_size: 16, + rotation_steps: [0], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png', default_scale: 0.11, + default_rotation_degrees: 0, default_position: { x: 182, y: -207 }, default_z_index: -14, + shop: { category: 'space', description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。', price: 220, tags: ['房间家具', '可拖拽', '挂件'], sort_order: 120 }, }, { decor_id: 'whale_tail_lamp', item_id: 'decor_whale_tail_lamp', name: '鲸尾暖灯', + category: 'floor', + placement_surface: 'floor', + grid_size: 16, + rotation_steps: [0, 90, 180, 270], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_lamp_roomfit.png', default_scale: 1, + default_rotation_degrees: 0, default_position: { x: 231, y: -175 }, default_z_index: -10, + shop: { category: 'space', description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。', price: 360, tags: ['房间家具', '可拖拽', '灯具'], sort_order: 130 }, collision_size: { x: 50, y: 32 }, collision_offset: { x: 0, y: 56 }, }, @@ -115,11 +149,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [ decor_id: 'boat_cabin_bed', item_id: 'decor_boat_cabin_bed', name: '船舱小床', + category: 'floor', + placement_surface: 'floor', + grid_size: 16, + rotation_steps: [0, 90, 180, 270], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png', default_scale: ROOM_DECOR_BED_DEFAULT_SCALE, + default_rotation_degrees: 0, default_position: { x: -161, y: 25 }, default_z_index: -9, + shop: { category: 'space', description: '白木船舱造型的小床,适合放在个人房间地面区域。', price: 520, tags: ['房间家具', '可拖拽', '床'], sort_order: 140 }, collision_size: { x: 220, y: 112 }, collision_offset: { x: 0, y: 52 }, }, @@ -127,11 +168,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [ decor_id: 'low_wave_bed', item_id: 'decor_low_wave_bed', name: '海浪低床', + category: 'floor', + placement_surface: 'floor', + grid_size: 16, + rotation_steps: [0, 90, 180, 270], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png', default_scale: ROOM_DECOR_BED_DEFAULT_SCALE, + default_rotation_degrees: 0, default_position: { x: -98, y: 39 }, default_z_index: -9, + shop: { category: 'space', description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。', price: 500, tags: ['房间家具', '可拖拽', '床'], sort_order: 150 }, collision_size: { x: 220, y: 112 }, collision_offset: { x: 0, y: 56 }, }, @@ -139,11 +187,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [ decor_id: 'whale_tail_headboard_bed', item_id: 'decor_whale_tail_headboard_bed', name: '鲸尾床头床', + category: 'floor', + placement_surface: 'floor', + grid_size: 16, + rotation_steps: [0, 90, 180, 270], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_headboard_bed_roomfit.png', default_scale: ROOM_DECOR_BED_DEFAULT_SCALE, + default_rotation_degrees: 0, default_position: { x: 0, y: 32 }, default_z_index: -9, + shop: { category: 'space', description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。', price: 580, tags: ['房间家具', '可拖拽', '床'], sort_order: 180 }, collision_size: { x: 214, y: 112 }, collision_offset: { x: 0, y: 62 }, }, @@ -151,11 +206,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [ decor_id: 'dev_whale_bookshelf', item_id: 'decor_dev_whale_bookshelf', name: '程序员鲸书架', + category: 'floor', + placement_surface: 'floor', + grid_size: 16, + rotation_steps: [0, 90, 180, 270], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png', texture: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png', default_scale: ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE, + default_rotation_degrees: 0, default_position: { x: -210, y: -39 }, default_z_index: -10, + shop: { category: 'space', description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。', price: 620, tags: ['房间家具', '可拖拽', '书架'], sort_order: 190 }, collision_size: { x: 626.667, y: 226.667 }, collision_offset: { x: 0, y: 580 }, }, @@ -163,31 +225,52 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [ decor_id: 'datawhale_bug_feature_badge', item_id: 'decor_datawhale_bug_feature_badge', name: 'BUG特性徽章', + category: 'wall', + placement_surface: 'wall', + grid_size: 16, + rotation_steps: [0], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_hires_clean.png', default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE, + default_rotation_degrees: 0, default_position: { x: -210, y: -203 }, default_z_index: -14, + shop: { category: 'space', description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 200 }, }, { decor_id: 'datawhale_buddhist_learning_badge', item_id: 'decor_datawhale_buddhist_learning_badge', name: '佛系学习徽章', + category: 'wall', + placement_surface: 'wall', + grid_size: 16, + rotation_steps: [0], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_hires_clean.png', default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE, + default_rotation_degrees: 0, default_position: { x: 0, y: -203 }, default_z_index: -14, + shop: { category: 'space', description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。', price: 140, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 210 }, }, { decor_id: 'datawhale_ok_working_badge', item_id: 'decor_datawhale_ok_working_badge', name: '已经在做徽章', + category: 'wall', + placement_surface: 'wall', + grid_size: 16, + rotation_steps: [0], + stackable: false, icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_hires_clean.png', default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE, + default_rotation_degrees: 0, default_position: { x: 210, y: -203 }, default_z_index: -14, + shop: { category: 'space', description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 220 }, }, ]; diff --git a/src/business/social/social.service.ts b/src/business/social/social.service.ts index 0b9331b..7c66bd2 100644 --- a/src/business/social/social.service.ts +++ b/src/business/social/social.service.ts @@ -40,6 +40,7 @@ const DEFAULT_PRIVACY = { allow_nearby_private: true, allow_nearby_friend_requests: true, }; +const ROOM_VISIT_POLICIES = new Set(['friends', 'public', 'closed']); const TRAVEL_MAP_ORIGINS: Record = { whale_port: { x: 1280, y: 960 }, work_zone: { x: 1280, y: 960 }, @@ -163,6 +164,26 @@ export class SocialService { return this.buildProfile(viewerId, targetId, self); } + async canVisitRoom(viewerId: bigint, ownerId: bigint): Promise { + if (viewerId === ownerId) return true; + const profile = await this.ensureProfile(ownerId); + return await this.canVisitRoomWithProfile(viewerId, ownerId, profile); + } + + async getRoomOwnerProfile(viewerId: bigint, ownerId: bigint) { + if (!(await this.canVisitRoom(viewerId, ownerId))) { + throw new ForbiddenException('该玩家的个人空间暂不可访问'); + } + const profile = await this.buildProfile(viewerId, ownerId, viewerId === ownerId); + return { + id: profile.id, + username: profile.username, + nickname: profile.nickname, + avatarUrl: profile.avatarUrl, + skinId: profile.skinId, + }; + } + async getFriends(userId: bigint) { const friendships = await this.store.listFriendships(userId); const result = []; @@ -416,6 +437,8 @@ export class SocialService { const tags = this.profileTags(profile); const session = socketId ? await this.sessions.getSession(socketId) : null; const testPresence = getTestLabPresence(targetId); + const isFriend = viewerId === targetId ? false : await this.areFriends(viewerId, targetId); + const blockedEitherWay = viewerId === targetId ? false : await this.isBlockedEitherWay(viewerId, targetId); return { id: targetId.toString(), username: user.username, @@ -427,8 +450,9 @@ export class SocialService { bio: String(profile.bio || '').slice(0, 160), interests: this.validInterests(tags.interests), privacy: includePrivate ? this.privacy(profile) : undefined, - isFriend: includePrivate || viewerId === targetId ? false : await this.areFriends(viewerId, targetId), - blocked: includePrivate ? false : await this.store.isBlocked(viewerId, targetId), + isFriend: includePrivate || viewerId === targetId ? false : isFriend, + blocked: includePrivate ? false : blockedEitherWay, + room_visitable: viewerId === targetId || (!blockedEitherWay && this.roomVisitPolicy(profile) === 'public') || (!blockedEitherWay && this.roomVisitPolicy(profile) === 'friends' && isFriend), }; } @@ -451,6 +475,23 @@ export class SocialService { return { ...DEFAULT_PRIVACY, ...(values && typeof values === 'object' ? values : {}) }; } + private roomVisitPolicy(profile: ProfileRecord): 'friends' | 'public' | 'closed' { + const value = this.profileTags(profile)[SOCIAL_SETTINGS_KEY]; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const policy = String((value as Record).room_visit_policy || 'friends'); + if (ROOM_VISIT_POLICIES.has(policy)) return policy as 'friends' | 'public' | 'closed'; + } + return 'friends'; + } + + private async canVisitRoomWithProfile(viewerId: bigint, ownerId: bigint, profile: ProfileRecord): Promise { + if (viewerId === ownerId) return true; + if (await this.isBlockedEitherWay(viewerId, ownerId)) return false; + const policy = this.roomVisitPolicy(profile); + if (policy === 'public') return true; + return policy === 'friends' && await this.areFriends(viewerId, ownerId); + } + private validInterests(value: unknown): string[] { return Array.isArray(value) ? value.map(String).filter((item) => INTEREST_IDS.has(item)).slice(0, 3) : []; } diff --git a/src/core/db/player_assets/create-player-assets-tables.sql b/src/core/db/player_assets/create-player-assets-tables.sql index b1c09da..5bd31bf 100644 --- a/src/core/db/player_assets/create-player-assets-tables.sql +++ b/src/core/db/player_assets/create-player-assets-tables.sql @@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS `room_decor_placements` ( `position_x` FLOAT NULL COMMENT '房间内X坐标', `position_y` FLOAT NULL COMMENT '房间内Y坐标', `scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放', + `rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度', `z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级', `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', diff --git a/src/core/db/player_assets/migrations/add-room-decor-placement-rotation.sql b/src/core/db/player_assets/migrations/add-room-decor-placement-rotation.sql new file mode 100644 index 0000000..769ea94 --- /dev/null +++ b/src/core/db/player_assets/migrations/add-room-decor-placement-rotation.sql @@ -0,0 +1,2 @@ +ALTER TABLE `room_decor_placements` + ADD COLUMN IF NOT EXISTS `rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度' AFTER `scale`; diff --git a/src/core/db/player_assets/room_decor_placements.entity.ts b/src/core/db/player_assets/room_decor_placements.entity.ts index e4506b9..8102964 100644 --- a/src/core/db/player_assets/room_decor_placements.entity.ts +++ b/src/core/db/player_assets/room_decor_placements.entity.ts @@ -25,6 +25,9 @@ export class RoomDecorPlacements { @Column({ type: 'float', nullable: false, default: 1, comment: '摆件缩放' }) scale: number; + @Column({ type: 'float', nullable: false, default: 0, comment: '摆件旋转角度' }) + rotation_degrees: number; + @Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' }) z_index: number; diff --git a/src/core/db/player_assets/room_decor_placements.service.ts b/src/core/db/player_assets/room_decor_placements.service.ts index 5bb46ae..e69407f 100644 --- a/src/core/db/player_assets/room_decor_placements.service.ts +++ b/src/core/db/player_assets/room_decor_placements.service.ts @@ -33,6 +33,7 @@ export class RoomDecorPlacementsService { row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null; row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null; row.scale = Number(placement.scale ?? row.scale ?? 1); + row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0); row.z_index = Number(placement.z_index ?? row.z_index ?? 0); row.updated_at = new Date(); return await this.placementsRepository.save(row); diff --git a/src/core/db/player_assets/room_decor_placements_memory.service.ts b/src/core/db/player_assets/room_decor_placements_memory.service.ts index 3e87a7a..a73a302 100644 --- a/src/core/db/player_assets/room_decor_placements_memory.service.ts +++ b/src/core/db/player_assets/room_decor_placements_memory.service.ts @@ -34,6 +34,7 @@ export class RoomDecorPlacementsMemoryService { row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null; row.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null; row.scale = Number(placement.scale ?? row.scale ?? 1); + row.rotation_degrees = Number(placement.rotation_degrees ?? row.rotation_degrees ?? 0); row.z_index = Number(placement.z_index ?? row.z_index ?? 0); row.updated_at = new Date(); return row; diff --git a/src/gateway/chat/chat.gateway.ts b/src/gateway/chat/chat.gateway.ts index 4a0a65d..00fb286 100644 --- a/src/gateway/chat/chat.gateway.ts +++ b/src/gateway/chat/chat.gateway.ts @@ -1108,6 +1108,11 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha } private legacyFriend(profile: any) { - return { userId: String(profile.id), username: String(profile.nickname || profile.username || '玩家'), online: Boolean(profile.online) }; + return { + userId: String(profile.id), + username: String(profile.nickname || profile.username || '玩家'), + online: Boolean(profile.online), + room_visitable: Boolean(profile.room_visitable), + }; } }