feat: add personal space visits and editor

This commit is contained in:
ANG-Server
2026-07-22 13:41:28 +08:00
parent a3341b6a3b
commit b6b32f6676
14 changed files with 288 additions and 135 deletions

View File

@@ -56,7 +56,8 @@ export interface AccountProfilePayload {
}; };
} }
export type AccountSettings = Record<string, boolean | number>; export type RoomVisitPolicy = 'friends' | 'public' | 'closed';
export type AccountSettings = Record<string, boolean | number | RoomVisitPolicy>;
export interface UpdateAccountProfileRequest { export interface UpdateAccountProfileRequest {
skin_id?: string; skin_id?: string;
@@ -101,6 +102,7 @@ const DEFAULT_ACCOUNT_SETTINGS: AccountSettings = {
allow_nearby_private: true, allow_nearby_private: true,
allow_nearby_friend_requests: true, allow_nearby_friend_requests: true,
allow_nearby_profile: true, allow_nearby_profile: true,
room_visit_policy: 'friends',
mute_ui_sfx: false, mute_ui_sfx: false,
}; };
const ACCOUNT_SETTING_NUMBER_KEYS = new Set(['master_volume', 'music_volume', 'effects_volume', 'ui_scale']); 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', 'allow_nearby_profile',
'mute_ui_sfx', 'mute_ui_sfx',
]); ]);
const ROOM_VISIT_POLICIES = new Set<RoomVisitPolicy>(['friends', 'public', 'closed']);
export interface AccountSkinAsset { export interface AccountSkinAsset {
id: string; id: string;
@@ -669,6 +672,8 @@ export class AccountProfileService {
} }
} else if (ACCOUNT_SETTING_BOOLEAN_KEYS.has(key)) { } else if (ACCOUNT_SETTING_BOOLEAN_KEYS.has(key)) {
sanitized[key] = typeof value === 'boolean' ? value : value === 'true' || value === 1 || value === '1'; 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; return sanitized;

View File

@@ -1,3 +1,5 @@
import { ROOM_DECOR_DEFINITIONS } from '../room_decor/room_decor_catalog';
export type MallItemType = 'skin' | 'room_decor'; export type MallItemType = 'skin' | 'room_decor';
export interface MallCatalogItem { export interface MallCatalogItem {
@@ -23,6 +25,19 @@ export const MALL_CATEGORIES = [
{ id: 'limited', label: '限时', icon: 'limited' }, { 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[] = [ export const MALL_ITEMS: MallCatalogItem[] = [
{ {
itemId: 'skin_classic_whale', itemId: 'skin_classic_whale',
@@ -81,126 +96,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
tags: ['可预览', '永久', '皮肤'], tags: ['可预览', '永久', '皮肤'],
sortOrder: 50, sortOrder: 50,
}, },
{ ...ROOM_DECOR_MALL_ITEMS,
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,
},
]; ];
export const MALL_SKIN_ITEMS = MALL_ITEMS.filter((item) => item.itemType === 'skin' && item.skinId); export const MALL_SKIN_ITEMS = MALL_ITEMS.filter((item) => item.itemType === 'skin' && item.skinId);

View File

@@ -48,5 +48,5 @@ export interface PlayerSnapshotPayload {
owned_skin_ids: string[]; owned_skin_ids: string[];
owned_skins: unknown[]; owned_skins: unknown[];
}; };
settings: Record<string, boolean | number>; settings: Record<string, boolean | number | string>;
} }

View File

@@ -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 { export class SaveRoomDecorPlacementDto {
@IsOptional()
@IsString({ message: '摆件ID必须是字符串' }) @IsString({ message: '摆件ID必须是字符串' })
@Length(1, 100, { message: '摆件ID长度需在1-100字符之间' }) @Length(1, 100, { message: '摆件ID长度需在1-100字符之间' })
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '摆件ID格式不正确' }) @Matches(/^[A-Za-z0-9_:-]+$/, { message: '摆件ID格式不正确' })
decor_id!: string; decor_id?: string;
@IsBoolean({ message: '摆放状态必须是布尔值' }) @IsBoolean({ message: '摆放状态必须是布尔值' })
placed!: boolean; placed!: boolean;
@@ -27,6 +28,13 @@ export class SaveRoomDecorPlacementDto {
@Max(4, { message: '缩放不能太大' }) @Max(4, { message: '缩放不能太大' })
scale?: number; scale?: number;
@IsOptional()
@IsNumber({}, { message: '旋转角度必须是数字' })
@IsInt({ message: '旋转角度必须是整数' })
@Min(0, { message: '旋转角度超出范围' })
@Max(270, { message: '旋转角度超出范围' })
rotation_degrees?: number;
@IsOptional() @IsOptional()
@IsNumber({}, { message: '层级必须是数字' }) @IsNumber({}, { message: '层级必须是数字' })
@Min(-1000, { message: '层级超出范围' }) @Min(-1000, { message: '层级超出范围' })

View File

@@ -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 { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { JwtPayload } from '../../core/login_core/login_core.service'; import { JwtPayload } from '../../core/login_core/login_core.service';
@@ -9,7 +9,7 @@ import { RoomDecorService } from './room_decor.service';
@ApiTags('room-decor') @ApiTags('room-decor')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('rooms/me/decor-placements') @Controller('rooms')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class RoomDecorController { export class RoomDecorController {
constructor(private readonly roomDecorService: RoomDecorService) {} constructor(private readonly roomDecorService: RoomDecorService) {}
@@ -22,7 +22,7 @@ export class RoomDecorController {
status: 200, status: 200,
description: '房间家具背包获取成功', description: '房间家具背包获取成功',
}) })
@Get() @Get('me/decor-placements')
async getInventory(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> { async getInventory(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
const data = await this.roomDecorService.getInventory(BigInt(user.sub)); const data = await this.roomDecorService.getInventory(BigInt(user.sub));
res.status(HttpStatus.OK).json({ 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<void> {
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<void> {
const data = await this.roomDecorService.resetPlacements(BigInt(user.sub));
res.status(HttpStatus.OK).json({
success: true,
data,
message: '房间布局已重置,家具已收回背包',
});
}
@ApiOperation({ @ApiOperation({
summary: '保存房间家具摆放', summary: '保存房间家具摆放',
description: '保存当前账号某个家具的摆放状态、位置、缩放和层级。', description: '保存当前账号某个家具的摆放状态、位置、缩放和层级。',
@@ -40,7 +74,7 @@ export class RoomDecorController {
status: 200, status: 200,
description: '家具摆放保存成功', description: '家具摆放保存成功',
}) })
@Put(':decorId') @Put('me/decor-placements/:decorId')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async savePlacement( async savePlacement(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@@ -58,4 +92,11 @@ export class RoomDecorController {
message: '家具摆放保存成功', message: '家具摆放保存成功',
}); });
} }
private parseUserId(value: string): bigint {
if (!/^\d+$/.test(value)) {
throw new BadRequestException('房主用户ID格式不正确');
}
return BigInt(value);
}
} }

View File

@@ -1,5 +1,6 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { InventoryService } from '../player/inventory.service'; import { InventoryService } from '../player/inventory.service';
import { SocialService } from '../social/social.service';
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto'; import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
import { import {
ROOM_DECOR_BED_DEFAULT_SCALE, ROOM_DECOR_BED_DEFAULT_SCALE,
@@ -8,6 +9,7 @@ import {
ROOM_DECOR_LEGACY_BED_MAX_SCALE, ROOM_DECOR_LEGACY_BED_MAX_SCALE,
ROOM_DECOR_LEGACY_WALL_DECOR_SCALES, ROOM_DECOR_LEGACY_WALL_DECOR_SCALES,
ROOM_DECOR_ROOM_SCALE, ROOM_DECOR_ROOM_SCALE,
RoomDecorDefinition,
findRoomDecorDefinition, findRoomDecorDefinition,
} from './room_decor_catalog'; } from './room_decor_catalog';
@@ -17,6 +19,7 @@ interface UserRoomDecorRow {
position_x: number | null; position_x: number | null;
position_y: number | null; position_y: number | null;
scale: number; scale: number;
rotation_degrees?: number;
z_index: number; z_index: number;
} }
@@ -29,6 +32,7 @@ interface RoomDecorPayloadPlacement {
position_x: number | null; position_x: number | null;
position_y: number | null; position_y: number | null;
scale: number; scale: number;
rotation_degrees: number;
} }
@Injectable() @Injectable()
@@ -36,6 +40,7 @@ export class RoomDecorService {
constructor( constructor(
@Inject('IRoomDecorPlacementsService') private readonly roomDecorPlacementsService: IRoomDecorPlacementsService, @Inject('IRoomDecorPlacementsService') private readonly roomDecorPlacementsService: IRoomDecorPlacementsService,
private readonly inventoryService: InventoryService, private readonly inventoryService: InventoryService,
private readonly socialService: SocialService,
) {} ) {}
async getInventory(userId: bigint) { async getInventory(userId: bigint) {
@@ -53,6 +58,7 @@ export class RoomDecorService {
position_x: definition?.default_position.x ?? null, position_x: definition?.default_position.x ?? null,
position_y: definition?.default_position.y ?? null, position_y: definition?.default_position.y ?? null,
scale: definition?.default_scale ?? 1, scale: definition?.default_scale ?? 1,
rotation_degrees: definition?.default_rotation_degrees ?? 0,
z_index: definition?.default_z_index ?? 0, z_index: definition?.default_z_index ?? 0,
}; };
}); });
@@ -65,21 +71,63 @@ export class RoomDecorService {
} }
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto) { async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto) {
const definition = findRoomDecorDefinition(placement.decor_id); const decorId = placement.decor_id?.trim() || '';
const definition = findRoomDecorDefinition(decorId);
if (!definition) { if (!definition) {
throw new BadRequestException('摆件不存在或暂未开放'); 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('尚未拥有该房间摆件'); 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, { const row = await this.roomDecorPlacementsService.savePlacement(userId, {
...placement, ...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, scale: placement.scale ?? definition.default_scale,
rotation_degrees: rotationDegrees,
z_index: placement.z_index ?? definition.default_z_index, z_index: placement.z_index ?? definition.default_z_index,
}); });
return this.toPayload(row); 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) { private toPayload(row: UserRoomDecorRow) {
const definition = findRoomDecorDefinition(row.decor_id); const definition = findRoomDecorDefinition(row.decor_id);
const placement = this.normalizedPlacement(row, definition); const placement = this.normalizedPlacement(row, definition);
@@ -93,26 +141,34 @@ export class RoomDecorService {
position_x: placement.position_x, position_x: placement.position_x,
position_y: placement.position_y, position_y: placement.position_y,
scale: placement.scale, scale: placement.scale,
rotation_degrees: placement.rotation_degrees,
z_index: row.z_index ?? definition?.default_z_index ?? 0, 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_position: definition?.default_position ?? { x: 0, y: 0 },
default_scale: definition?.default_scale ?? 1, default_scale: definition?.default_scale ?? 1,
default_rotation_degrees: definition?.default_rotation_degrees ?? 0,
default_z_index: definition?.default_z_index ?? 0, default_z_index: definition?.default_z_index ?? 0,
}; };
} }
private normalizedPlacement( private normalizedPlacement(
row: UserRoomDecorRow, row: UserRoomDecorRow,
definition?: { default_scale: number; default_position: { x: number; y: number } }, definition?: RoomDecorDefinition,
): RoomDecorPayloadPlacement { ): RoomDecorPayloadPlacement {
const usesLegacyPlacement = this.usesLegacyPlacement(row); const usesLegacyPlacement = this.usesLegacyPlacement(row);
return { return {
position_x: this.normalizedPositionValue(row.position_x, definition?.default_position.x ?? 0, usesLegacyPlacement), 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), position_y: this.normalizedPositionValue(row.position_y, definition?.default_position.y ?? 0, usesLegacyPlacement),
scale: this.normalizedScale(row, definition), 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<RoomDecorDefinition, 'default_scale'>) {
const scale = row.scale ?? definition?.default_scale ?? 1; const scale = row.scale ?? definition?.default_scale ?? 1;
if (!row.placed && definition) { if (!row.placed && definition) {
return definition.default_scale; return definition.default_scale;
@@ -146,6 +202,16 @@ export class RoomDecorService {
return usesLegacyPlacement ? Math.round(value * ROOM_DECOR_ROOM_SCALE) : value; 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) { private usesLegacyPlacement(row: UserRoomDecorRow) {
const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id]; const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id];
if (!legacy) { if (!legacy) {

View File

@@ -2,14 +2,27 @@ export interface RoomDecorDefinition {
decor_id: string; decor_id: string;
name: string; name: string;
item_id: string; item_id: string;
category: 'floor' | 'rug' | 'wall';
placement_surface: 'floor' | 'wall';
grid_size: number;
rotation_steps: number[];
stackable: boolean;
icon: string; icon: string;
texture?: string; texture?: string;
default_scale: number; default_scale: number;
default_rotation_degrees: number;
default_position: { default_position: {
x: number; x: number;
y: number; y: number;
}; };
default_z_index: number; default_z_index: number;
shop: {
category: 'space';
description: string;
price: number;
tags: string[];
sort_order: number;
};
collision_size?: { collision_size?: {
x: number; x: number;
y: number; y: number;
@@ -84,30 +97,51 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'whale_floor_rug', decor_id: 'whale_floor_rug',
item_id: 'decor_whale_floor_rug', item_id: 'decor_whale_floor_rug',
name: '鲸浪地毯', 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', 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', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png',
default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE, default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: 0, y: 161 }, default_position: { x: 0, y: 161 },
default_z_index: -8, default_z_index: -8,
shop: { category: 'space', description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。', price: 260, tags: ['房间家具', '可拖拽', '地面'], sort_order: 110 },
}, },
{ {
decor_id: 'whale_memory_board', decor_id: 'whale_memory_board',
item_id: 'decor_whale_memory_board', item_id: 'decor_whale_memory_board',
name: '鲸语记忆板', 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', icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
default_scale: 0.11, default_scale: 0.11,
default_rotation_degrees: 0,
default_position: { x: 182, y: -207 }, default_position: { x: 182, y: -207 },
default_z_index: -14, default_z_index: -14,
shop: { category: 'space', description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。', price: 220, tags: ['房间家具', '可拖拽', '挂件'], sort_order: 120 },
}, },
{ {
decor_id: 'whale_tail_lamp', decor_id: 'whale_tail_lamp',
item_id: 'decor_whale_tail_lamp', item_id: 'decor_whale_tail_lamp',
name: '鲸尾暖灯', 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', 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', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_lamp_roomfit.png',
default_scale: 1, default_scale: 1,
default_rotation_degrees: 0,
default_position: { x: 231, y: -175 }, default_position: { x: 231, y: -175 },
default_z_index: -10, default_z_index: -10,
shop: { category: 'space', description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。', price: 360, tags: ['房间家具', '可拖拽', '灯具'], sort_order: 130 },
collision_size: { x: 50, y: 32 }, collision_size: { x: 50, y: 32 },
collision_offset: { x: 0, y: 56 }, collision_offset: { x: 0, y: 56 },
}, },
@@ -115,11 +149,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'boat_cabin_bed', decor_id: 'boat_cabin_bed',
item_id: 'decor_boat_cabin_bed', item_id: 'decor_boat_cabin_bed',
name: '船舱小床', 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', 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', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png',
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE, default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: -161, y: 25 }, default_position: { x: -161, y: 25 },
default_z_index: -9, default_z_index: -9,
shop: { category: 'space', description: '白木船舱造型的小床,适合放在个人房间地面区域。', price: 520, tags: ['房间家具', '可拖拽', '床'], sort_order: 140 },
collision_size: { x: 220, y: 112 }, collision_size: { x: 220, y: 112 },
collision_offset: { x: 0, y: 52 }, collision_offset: { x: 0, y: 52 },
}, },
@@ -127,11 +168,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'low_wave_bed', decor_id: 'low_wave_bed',
item_id: 'decor_low_wave_bed', item_id: 'decor_low_wave_bed',
name: '海浪低床', 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', 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', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png',
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE, default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: -98, y: 39 }, default_position: { x: -98, y: 39 },
default_z_index: -9, default_z_index: -9,
shop: { category: 'space', description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。', price: 500, tags: ['房间家具', '可拖拽', '床'], sort_order: 150 },
collision_size: { x: 220, y: 112 }, collision_size: { x: 220, y: 112 },
collision_offset: { x: 0, y: 56 }, collision_offset: { x: 0, y: 56 },
}, },
@@ -139,11 +187,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'whale_tail_headboard_bed', decor_id: 'whale_tail_headboard_bed',
item_id: 'decor_whale_tail_headboard_bed', item_id: 'decor_whale_tail_headboard_bed',
name: '鲸尾床头床', 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', 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', texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_headboard_bed_roomfit.png',
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE, default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: 0, y: 32 }, default_position: { x: 0, y: 32 },
default_z_index: -9, default_z_index: -9,
shop: { category: 'space', description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。', price: 580, tags: ['房间家具', '可拖拽', '床'], sort_order: 180 },
collision_size: { x: 214, y: 112 }, collision_size: { x: 214, y: 112 },
collision_offset: { x: 0, y: 62 }, collision_offset: { x: 0, y: 62 },
}, },
@@ -151,11 +206,18 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'dev_whale_bookshelf', decor_id: 'dev_whale_bookshelf',
item_id: 'decor_dev_whale_bookshelf', item_id: 'decor_dev_whale_bookshelf',
name: '程序员鲸书架', 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', icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
texture: '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_scale: ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: -210, y: -39 }, default_position: { x: -210, y: -39 },
default_z_index: -10, 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_size: { x: 626.667, y: 226.667 },
collision_offset: { x: 0, y: 580 }, collision_offset: { x: 0, y: 580 },
}, },
@@ -163,31 +225,52 @@ export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
decor_id: 'datawhale_bug_feature_badge', decor_id: 'datawhale_bug_feature_badge',
item_id: 'decor_datawhale_bug_feature_badge', item_id: 'decor_datawhale_bug_feature_badge',
name: 'BUG特性徽章', 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', 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', 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_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: -210, y: -203 }, default_position: { x: -210, y: -203 },
default_z_index: -14, default_z_index: -14,
shop: { category: 'space', description: '写着“这不是BUG 这是feature”的佛系学习小徽章适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 200 },
}, },
{ {
decor_id: 'datawhale_buddhist_learning_badge', decor_id: 'datawhale_buddhist_learning_badge',
item_id: 'decor_datawhale_buddhist_learning_badge', item_id: 'decor_datawhale_buddhist_learning_badge',
name: '佛系学习徽章', 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', 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', 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_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: 0, y: -203 }, default_position: { x: 0, y: -203 },
default_z_index: -14, default_z_index: -14,
shop: { category: 'space', description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。', price: 140, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 210 },
}, },
{ {
decor_id: 'datawhale_ok_working_badge', decor_id: 'datawhale_ok_working_badge',
item_id: 'decor_datawhale_ok_working_badge', item_id: 'decor_datawhale_ok_working_badge',
name: '已经在做徽章', 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', 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', 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_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
default_rotation_degrees: 0,
default_position: { x: 210, y: -203 }, default_position: { x: 210, y: -203 },
default_z_index: -14, default_z_index: -14,
shop: { category: 'space', description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。', price: 120, tags: ['房间家具', '可拖拽', '徽章'], sort_order: 220 },
}, },
]; ];

View File

@@ -40,6 +40,7 @@ const DEFAULT_PRIVACY = {
allow_nearby_private: true, allow_nearby_private: true,
allow_nearby_friend_requests: true, allow_nearby_friend_requests: true,
}; };
const ROOM_VISIT_POLICIES = new Set(['friends', 'public', 'closed']);
const TRAVEL_MAP_ORIGINS: Record<string, { x: number; y: number }> = { const TRAVEL_MAP_ORIGINS: Record<string, { x: number; y: number }> = {
whale_port: { x: 1280, y: 960 }, whale_port: { x: 1280, y: 960 },
work_zone: { x: 1280, y: 960 }, work_zone: { x: 1280, y: 960 },
@@ -163,6 +164,26 @@ export class SocialService {
return this.buildProfile(viewerId, targetId, self); return this.buildProfile(viewerId, targetId, self);
} }
async canVisitRoom(viewerId: bigint, ownerId: bigint): Promise<boolean> {
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) { async getFriends(userId: bigint) {
const friendships = await this.store.listFriendships(userId); const friendships = await this.store.listFriendships(userId);
const result = []; const result = [];
@@ -416,6 +437,8 @@ export class SocialService {
const tags = this.profileTags(profile); const tags = this.profileTags(profile);
const session = socketId ? await this.sessions.getSession(socketId) : null; const session = socketId ? await this.sessions.getSession(socketId) : null;
const testPresence = getTestLabPresence(targetId); 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 { return {
id: targetId.toString(), id: targetId.toString(),
username: user.username, username: user.username,
@@ -427,8 +450,9 @@ export class SocialService {
bio: String(profile.bio || '').slice(0, 160), bio: String(profile.bio || '').slice(0, 160),
interests: this.validInterests(tags.interests), interests: this.validInterests(tags.interests),
privacy: includePrivate ? this.privacy(profile) : undefined, privacy: includePrivate ? this.privacy(profile) : undefined,
isFriend: includePrivate || viewerId === targetId ? false : await this.areFriends(viewerId, targetId), isFriend: includePrivate || viewerId === targetId ? false : isFriend,
blocked: includePrivate ? false : await this.store.isBlocked(viewerId, targetId), 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 : {}) }; 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<string, unknown>).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<boolean> {
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[] { private validInterests(value: unknown): string[] {
return Array.isArray(value) ? value.map(String).filter((item) => INTEREST_IDS.has(item)).slice(0, 3) : []; return Array.isArray(value) ? value.map(String).filter((item) => INTEREST_IDS.has(item)).slice(0, 3) : [];
} }

View File

@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS `room_decor_placements` (
`position_x` FLOAT NULL COMMENT '房间内X坐标', `position_x` FLOAT NULL COMMENT '房间内X坐标',
`position_y` FLOAT NULL COMMENT '房间内Y坐标', `position_y` FLOAT NULL COMMENT '房间内Y坐标',
`scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放', `scale` FLOAT NOT NULL DEFAULT 1 COMMENT '摆件缩放',
`rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度',
`z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级', `z_index` INT NOT NULL DEFAULT 0 COMMENT '摆放层级',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

View File

@@ -0,0 +1,2 @@
ALTER TABLE `room_decor_placements`
ADD COLUMN IF NOT EXISTS `rotation_degrees` FLOAT NOT NULL DEFAULT 0 COMMENT '摆件旋转角度' AFTER `scale`;

View File

@@ -25,6 +25,9 @@ export class RoomDecorPlacements {
@Column({ type: 'float', nullable: false, default: 1, comment: '摆件缩放' }) @Column({ type: 'float', nullable: false, default: 1, comment: '摆件缩放' })
scale: number; scale: number;
@Column({ type: 'float', nullable: false, default: 0, comment: '摆件旋转角度' })
rotation_degrees: number;
@Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' }) @Column({ type: 'int', nullable: false, default: 0, comment: '摆放层级' })
z_index: number; z_index: number;

View File

@@ -33,6 +33,7 @@ export class RoomDecorPlacementsService {
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null; 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.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
row.scale = Number(placement.scale ?? row.scale ?? 1); 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.z_index = Number(placement.z_index ?? row.z_index ?? 0);
row.updated_at = new Date(); row.updated_at = new Date();
return await this.placementsRepository.save(row); return await this.placementsRepository.save(row);

View File

@@ -34,6 +34,7 @@ export class RoomDecorPlacementsMemoryService {
row.position_x = placement.placed ? Number(placement.position_x ?? row.position_x ?? 0) : null; 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.position_y = placement.placed ? Number(placement.position_y ?? row.position_y ?? 0) : null;
row.scale = Number(placement.scale ?? row.scale ?? 1); 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.z_index = Number(placement.z_index ?? row.z_index ?? 0);
row.updated_at = new Date(); row.updated_at = new Date();
return row; return row;

View File

@@ -1108,6 +1108,11 @@ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, ICha
} }
private legacyFriend(profile: any) { 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),
};
} }
} }