feat: consolidate skin policy, furniture and world NPC work
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,6 +17,7 @@ test-setup.js
|
|||||||
!src/business/chat/chat.service.spec.ts
|
!src/business/chat/chat.service.spec.ts
|
||||||
!src/business/chat/services/chat_session.service.spec.ts
|
!src/business/chat/services/chat_session.service.spec.ts
|
||||||
!src/business/mall/mall.service.spec.ts
|
!src/business/mall/mall.service.spec.ts
|
||||||
|
!src/business/room_decor/room_decor.service.spec.ts
|
||||||
!src/business/world_npc/world_npc.service.spec.ts
|
!src/business/world_npc/world_npc.service.spec.ts
|
||||||
!src/gateway/auth/register.controller.spec.ts
|
!src/gateway/auth/register.controller.spec.ts
|
||||||
!src/gateway/chat/chat.gateway.spec.ts
|
!src/gateway/chat/chat.gateway.spec.ts
|
||||||
@@ -72,3 +73,5 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
!src/business/auth/skin_defaults.spec.ts
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
"start:prod": "node dist/main.js",
|
"start:prod": "node dist/main.js",
|
||||||
"db:migrate": "node --env-file=.env -r ts-node/register scripts/run_migrations.ts",
|
"db:migrate": "node --env-file=.env -r ts-node/register scripts/run_migrations.ts",
|
||||||
"character-maker": "python3 tools/character_maker/app.py",
|
"character-maker": "python3 tools/character_maker/app.py",
|
||||||
"test": "jest --runInBand --runTestsByPath src/business/auth/register.service.spec.ts src/business/chat/chat.service.spec.ts src/business/chat/services/chat_session.service.spec.ts src/business/mall/mall.service.spec.ts src/gateway/auth/register.controller.spec.ts src/gateway/chat/chat.gateway.spec.ts src/business/world_npc/world_npc.service.spec.ts",
|
"test": "jest --runInBand --runTestsByPath src/business/auth/register.service.spec.ts src/business/chat/chat.service.spec.ts src/business/chat/services/chat_session.service.spec.ts src/business/mall/mall.service.spec.ts src/business/room_decor/room_decor.service.spec.ts src/gateway/auth/register.controller.spec.ts src/gateway/chat/chat.gateway.spec.ts src/business/world_npc/world_npc.service.spec.ts src/business/auth/skin_defaults.spec.ts",
|
||||||
"test:affected": "jest --runInBand --runTestsByPath src/business/auth/register.service.spec.ts src/business/chat/chat.service.spec.ts src/business/chat/services/chat_session.service.spec.ts src/business/mall/mall.service.spec.ts src/gateway/auth/register.controller.spec.ts src/gateway/chat/chat.gateway.spec.ts src/business/world_npc/world_npc.service.spec.ts",
|
"test:affected": "jest --runInBand --runTestsByPath src/business/auth/register.service.spec.ts src/business/chat/chat.service.spec.ts src/business/chat/services/chat_session.service.spec.ts src/business/mall/mall.service.spec.ts src/business/room_decor/room_decor.service.spec.ts src/gateway/auth/register.controller.spec.ts src/gateway/chat/chat.gateway.spec.ts src/business/world_npc/world_npc.service.spec.ts src/business/auth/skin_defaults.spec.ts",
|
||||||
"test:world-npc": "ts-node --transpile-only scripts/test_world_npc_runtime.ts"
|
"test:world-npc": "ts-node --transpile-only scripts/test_world_npc_runtime.ts"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { WORLD_LOCATIONS, WORLD_ROUTE_EDGES } from '../src/business/world_npc/world_npc.world';
|
import { getWorldLocation, WORLD_LOCATIONS, WORLD_ROUTE_EDGES } from '../src/business/world_npc/world_npc.world';
|
||||||
|
import { WORLD_NPC_DEFINITIONS } from '../src/business/world_npc/world_npc.registry';
|
||||||
|
|
||||||
const output = String(process.env.WORLD_NPC_GRAPH_OUTPUT || '').trim();
|
const output = String(process.env.WORLD_NPC_GRAPH_OUTPUT || '').trim();
|
||||||
if (!output) throw new Error('WORLD_NPC_GRAPH_OUTPUT is required');
|
if (!output) throw new Error('WORLD_NPC_GRAPH_OUTPUT is required');
|
||||||
writeFileSync(resolve(output), JSON.stringify({
|
writeFileSync(resolve(output), JSON.stringify({
|
||||||
locations: WORLD_LOCATIONS,
|
locations: WORLD_LOCATIONS,
|
||||||
edges: WORLD_ROUTE_EDGES,
|
edges: WORLD_ROUTE_EDGES,
|
||||||
|
fixedNpcPositions: WORLD_NPC_DEFINITIONS.filter((definition) => definition.stationary).map((definition) => ({
|
||||||
|
npcId: definition.npcId,
|
||||||
|
mapId: getWorldLocation(definition.homeLocationId).mapId,
|
||||||
|
...definition.fixedPosition,
|
||||||
|
})),
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
console.log(`WORLD_NPC_GRAPH_EXPORTED: ${resolve(output)}`);
|
console.log(`WORLD_NPC_GRAPH_EXPORTED: ${resolve(output)}`);
|
||||||
|
|||||||
56
scripts/migrate_default_skins.cjs
Normal file
56
scripts/migrate_default_skins.cjs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Run inside the backend runtime: node scripts/migrate_default_skins.cjs
|
||||||
|
// Apply only with --apply /protected/backup.json. Unrelated accounts/assets are unchanged.
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const BOY = 'human_whale_directional_v2_8x4';
|
||||||
|
const RETIRED = 'classic_whale';
|
||||||
|
|
||||||
|
async function migrate(connection, backupPath) {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
try {
|
||||||
|
const [profiles] = await connection.query(
|
||||||
|
"SELECT id,user_id,skin_id FROM user_profiles WHERE skin_id IN ('classic_whale','pending_initial_skin','') OR skin_id IS NULL FOR UPDATE");
|
||||||
|
const [assets] = await connection.query(
|
||||||
|
"SELECT * FROM user_assets WHERE asset_type='skin' AND asset_id='classic_whale' FOR UPDATE");
|
||||||
|
const userIds = [...new Set([...profiles, ...assets].map(row=>String(row.user_id)))];
|
||||||
|
const existingBoyAssets = userIds.length ? (await connection.query(
|
||||||
|
"SELECT id,user_id FROM user_assets WHERE asset_type='skin' AND asset_id=? AND user_id IN (?) FOR UPDATE",[BOY,userIds]))[0] : [];
|
||||||
|
const summary = {profiles:profiles.length,classicAssets:assets.length,affectedAccounts:userIds.length};
|
||||||
|
if (!backupPath) { await connection.rollback(); return {...summary,dryRun:true}; }
|
||||||
|
fs.writeFileSync(backupPath, JSON.stringify({createdAt:new Date().toISOString(),profiles,assets,existingBoyAssets},null,2)+'\n',{mode:0o600,flag:'wx'});
|
||||||
|
for (const userId of userIds) {
|
||||||
|
await connection.execute("INSERT INTO user_assets (user_id,asset_type,asset_id,source) VALUES (?,'skin',?,'default_skin_migration') ON DUPLICATE KEY UPDATE asset_id=VALUES(asset_id)",[userId,BOY]);
|
||||||
|
}
|
||||||
|
await connection.execute("UPDATE user_profiles SET skin_id=? WHERE skin_id IN ('classic_whale','pending_initial_skin','') OR skin_id IS NULL",[BOY]);
|
||||||
|
await connection.execute("DELETE FROM user_assets WHERE asset_type='skin' AND asset_id=?",[RETIRED]);
|
||||||
|
const [[check]] = await connection.query("SELECT (SELECT COUNT(*) FROM user_profiles WHERE skin_id='classic_whale') AS profiles, (SELECT COUNT(*) FROM user_assets WHERE asset_type='skin' AND asset_id='classic_whale') AS assets");
|
||||||
|
if (Number(check.profiles) || Number(check.assets)) throw new Error('Retired skin references remain');
|
||||||
|
await connection.commit();
|
||||||
|
return {...summary,dryRun:false,remainingClassicProfiles:0,remainingClassicAssets:0};
|
||||||
|
} catch (error) { await connection.rollback(); throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollback(connection, backupPath) {
|
||||||
|
const backup=JSON.parse(fs.readFileSync(backupPath,'utf8'));
|
||||||
|
const hadBoy=new Set(backup.existingBoyAssets.map(row=>String(row.user_id)));
|
||||||
|
const users=new Set([...backup.profiles,...backup.assets].map(row=>String(row.user_id)));
|
||||||
|
await connection.beginTransaction();
|
||||||
|
try {
|
||||||
|
for(const row of backup.profiles) await connection.execute('UPDATE user_profiles SET skin_id=? WHERE id=? AND skin_id=?',[row.skin_id,row.id,BOY]);
|
||||||
|
for(const row of backup.assets) await connection.execute('INSERT INTO user_assets (id,user_id,asset_type,asset_id,source,metadata,acquired_at) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE asset_id=VALUES(asset_id)',[row.id,row.user_id,row.asset_type,row.asset_id,row.source,row.metadata?JSON.stringify(row.metadata):null,new Date(row.acquired_at)]);
|
||||||
|
for(const userId of users) if(!hadBoy.has(userId)) await connection.execute("DELETE FROM user_assets WHERE user_id=? AND asset_type='skin' AND asset_id=? AND source='default_skin_migration'",[userId,BOY]);
|
||||||
|
await connection.commit();
|
||||||
|
return {rolledBack:true,profiles:backup.profiles.length,assets:backup.assets.length};
|
||||||
|
}catch(e){await connection.rollback();throw e;}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args=process.argv.slice(2);
|
||||||
|
if (args.length && (!['--apply','--rollback'].includes(args[0]) || args.length!==2)) throw new Error('Use --apply /protected/backup.json or no arguments for a dry run');
|
||||||
|
for(const key of ['DB_HOST','DB_USERNAME','DB_PASSWORD','DB_NAME']) if(!process.env[key]) throw new Error('Missing '+key);
|
||||||
|
const connection=await mysql.createConnection({host:process.env.DB_HOST,port:Number(process.env.DB_PORT||3306),user:process.env.DB_USERNAME,password:process.env.DB_PASSWORD,database:process.env.DB_NAME,charset:'utf8mb4',supportBigNumbers:true,bigNumberStrings:true});
|
||||||
|
try { console.log(JSON.stringify(await (args[0]==='--rollback'?rollback(connection,args[1]):migrate(connection,args[1])))); }
|
||||||
|
finally { await connection.end(); }
|
||||||
|
}
|
||||||
|
module.exports={migrate,rollback};
|
||||||
|
if(require.main===module) main().catch(e=>{console.error(e.message);process.exitCode=1;});
|
||||||
@@ -290,7 +290,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
for (const [npcId, expected] of [
|
for (const [npcId, expected] of [
|
||||||
['npc_town_mayor', { locationId: 'square_guild_reception', x: -199, y: -515 }],
|
['npc_town_mayor', { locationId: 'square_guild_reception', x: -199, y: -515 }],
|
||||||
['npc_dock_guide', { locationId: 'square_dock_guide', x: -825, y: 437 }],
|
['npc_dock_guide', { locationId: 'square_dock_guide', x: -825, y: 475 }],
|
||||||
] as const) {
|
] as const) {
|
||||||
const stationaryService = new WorldNpcService(planner);
|
const stationaryService = new WorldNpcService(planner);
|
||||||
const definition = stationaryService.getTownStatus(dayStart).npcs.find((npc) => npc.definition.npcId === npcId)!.definition;
|
const definition = stationaryService.getTownStatus(dayStart).npcs.find((npc) => npc.definition.npcId === npcId)!.definition;
|
||||||
|
|||||||
@@ -69,10 +69,9 @@ export interface UpdateAccountProfileRequest {
|
|||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_SKIN_ID = 'classic_whale';
|
const FALLBACK_SKIN_ID = 'human_whale_directional_v2_8x4';
|
||||||
const LEGACY_PENDING_INITIAL_SKIN_ID = 'pending_initial_skin';
|
const LEGACY_PENDING_INITIAL_SKIN_ID = 'pending_initial_skin';
|
||||||
const INITIAL_SKIN_IDS = new Set([
|
const INITIAL_SKIN_IDS = new Set([
|
||||||
'classic_whale',
|
|
||||||
'human_whale_directional_v2_8x4',
|
'human_whale_directional_v2_8x4',
|
||||||
'girl_sailor_turnaround_v2_8x4',
|
'girl_sailor_turnaround_v2_8x4',
|
||||||
]);
|
]);
|
||||||
@@ -147,6 +146,9 @@ export class AccountProfileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateAccountProfile(userId: bigint, update: UpdateAccountProfileRequest): Promise<AccountProfilePayload> {
|
async updateAccountProfile(userId: bigint, update: UpdateAccountProfileRequest): Promise<AccountProfilePayload> {
|
||||||
|
if (update.skin_image_base64) {
|
||||||
|
this.assertCustomSkinCreationAvailable();
|
||||||
|
}
|
||||||
const user = await this.usersService.findOne(userId);
|
const user = await this.usersService.findOne(userId);
|
||||||
let profile = await this.ensureProfile(userId);
|
let profile = await this.ensureProfile(userId);
|
||||||
const isInitialCharacterCreation = this.isInitialCharacterPending(profile);
|
const isInitialCharacterCreation = this.isInitialCharacterPending(profile);
|
||||||
@@ -249,7 +251,7 @@ export class AccountProfileService {
|
|||||||
user_id: userId,
|
user_id: userId,
|
||||||
skin_id: skinId,
|
skin_id: skinId,
|
||||||
tags: {
|
tags: {
|
||||||
[REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY]: true,
|
[REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY]: false,
|
||||||
[INITIAL_SKIN_SELECTION_AVAILABLE_TAG_KEY]: true,
|
[INITIAL_SKIN_SELECTION_AVAILABLE_TAG_KEY]: true,
|
||||||
},
|
},
|
||||||
current_map: 'plaza',
|
current_map: 'plaza',
|
||||||
@@ -330,10 +332,12 @@ export class AccountProfileService {
|
|||||||
return skinIds.some((skinId) => skinId.startsWith('generated_'));
|
return skinIds.some((skinId) => skinId.startsWith('generated_'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async canUseRegistrationSkinGeneration(userId: bigint): Promise<boolean> {
|
assertCustomSkinCreationAvailable(): void {
|
||||||
const profile = await this.ensureProfile(userId);
|
throw new ForbiddenException('自定义与上传皮肤暂未开放');
|
||||||
const tags = this.getProfileTags(profile);
|
}
|
||||||
return tags[REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY] === true;
|
|
||||||
|
async canUseRegistrationSkinGeneration(_userId: bigint): Promise<boolean> {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async consumeRegistrationSkinGeneration(userId: bigint): Promise<void> {
|
async consumeRegistrationSkinGeneration(userId: bigint): Promise<void> {
|
||||||
|
|||||||
62
src/business/auth/skin_defaults.spec.ts
Normal file
62
src/business/auth/skin_defaults.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { AccountProfileService } from './account_profile.service';
|
||||||
|
import { SkinGenerationService } from '../skin_generation/skin_generation.service';
|
||||||
|
import { MALL_ITEMS } from '../mall/mall_catalog';
|
||||||
|
|
||||||
|
const BOY = 'human_whale_directional_v2_8x4';
|
||||||
|
const GIRL = 'girl_sailor_turnaround_v2_8x4';
|
||||||
|
|
||||||
|
describe('Default character policy', () => {
|
||||||
|
let service: AccountProfileService;
|
||||||
|
let profile: any;
|
||||||
|
let profiles: any;
|
||||||
|
let assets: any;
|
||||||
|
let users: any;
|
||||||
|
beforeEach(() => {
|
||||||
|
profile = undefined;
|
||||||
|
const owned = new Set<string>();
|
||||||
|
profiles = {
|
||||||
|
findByUserId: jest.fn(async () => profile),
|
||||||
|
create: jest.fn(async data => (profile = { id: 10n, ...data })),
|
||||||
|
update: jest.fn(async (_id, data) => Object.assign(profile, data)),
|
||||||
|
};
|
||||||
|
assets = {
|
||||||
|
grantAsset: jest.fn(async (_user, _type, id) => owned.add(id)),
|
||||||
|
hasAsset: jest.fn(async (_user, _type, id) => owned.has(id)),
|
||||||
|
listAssetIds: jest.fn(async () => [...owned]),
|
||||||
|
};
|
||||||
|
users = { findOne: jest.fn(async () => ({id: 7n, username:'test'})) };
|
||||||
|
service = new AccountProfileService(users, profiles, assets,
|
||||||
|
{ensureWallet: jest.fn()} as any, {get: jest.fn()} as any, {sendWelcomeEmail: jest.fn()} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([undefined, '', 'classic_whale', 'panda_hero_8x4'])('uses boy instead of unsupported initial skin %s', async id => {
|
||||||
|
expect((await service.ensureProfile(7n, id)).skin_id).toBe(BOY);
|
||||||
|
expect(await assets.listAssetIds()).toEqual([BOY]);
|
||||||
|
});
|
||||||
|
it('lets a new player choose the girl and persists the selection', async () => {
|
||||||
|
await service.ensureProfile(7n);
|
||||||
|
const result = await service.updateAccountProfile(7n, {skin_id:GIRL});
|
||||||
|
expect(result.profile.skin_id).toBe(GIRL);
|
||||||
|
expect(profile.tags.initial_skin_selection_available).toBe(false);
|
||||||
|
expect(profile.tags.registration_skin_generation_available).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not grant the retired classic whale through initial selection', async () => {
|
||||||
|
await service.ensureProfile(7n);
|
||||||
|
await expect(service.updateAccountProfile(7n, {skin_id:'classic_whale'})).rejects.toThrow('尚未拥有');
|
||||||
|
expect(await assets.listAssetIds()).toEqual([BOY]);
|
||||||
|
expect(MALL_ITEMS.some(x=>x.skinId==='classic_whale')).toBe(false);
|
||||||
|
});
|
||||||
|
it('blocks upload before account writes or image processing', async () => {
|
||||||
|
await expect(service.updateAccountProfile(7n, {skin_image_base64:'test'})).rejects.toThrow('暂未开放');
|
||||||
|
expect(users.findOne).not.toHaveBeenCalled();
|
||||||
|
expect(profiles.create).not.toHaveBeenCalled();
|
||||||
|
expect(assets.grantAsset).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('blocks generation before checking credentials or launching workers', async () => {
|
||||||
|
const config = {get: jest.fn()};
|
||||||
|
const generator = new SkinGenerationService(config as any, service);
|
||||||
|
await expect(generator.createJob(7n,{source_image_base64:'test'})).rejects.toThrow('暂未开放');
|
||||||
|
expect(config.get).not.toHaveBeenCalled();
|
||||||
|
expect(await service.canUseRegistrationSkinGeneration(7n)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,22 +24,11 @@ export const MALL_CATEGORIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const MALL_ITEMS: MallCatalogItem[] = [
|
export const MALL_ITEMS: MallCatalogItem[] = [
|
||||||
{
|
|
||||||
itemId: 'skin_classic_whale',
|
|
||||||
itemType: 'skin',
|
|
||||||
skinId: 'classic_whale',
|
|
||||||
name: '经典鲸鱼',
|
|
||||||
category: 'outfit',
|
|
||||||
description: '圆润、轻快的鲸鱼居民皮肤,适合喜欢海洋感角色的玩家。',
|
|
||||||
price: 680,
|
|
||||||
tags: ['可预览', '永久', '皮肤'],
|
|
||||||
sortOrder: 10,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
itemId: 'skin_human_whale_directional_v2_8x4',
|
itemId: 'skin_human_whale_directional_v2_8x4',
|
||||||
itemType: 'skin',
|
itemType: 'skin',
|
||||||
skinId: 'human_whale_directional_v2_8x4',
|
skinId: 'human_whale_directional_v2_8x4',
|
||||||
name: '海风行者',
|
name: '海风少年',
|
||||||
category: 'outfit',
|
category: 'outfit',
|
||||||
description: '蓝白海风主题的人类角色皮肤,带有鲸鱼小镇风格的服装细节。',
|
description: '蓝白海风主题的人类角色皮肤,带有鲸鱼小镇风格的服装细节。',
|
||||||
price: 680,
|
price: 680,
|
||||||
@@ -85,7 +74,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_whale_floor_rug',
|
itemId: 'decor_whale_floor_rug',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'whale_floor_rug',
|
decorId: 'whale_floor_rug',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
icon: 'res://assets/ui/mall/furniture/whale_floor_rug.png',
|
||||||
name: '鲸浪地毯',
|
name: '鲸浪地毯',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。',
|
description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。',
|
||||||
@@ -97,7 +86,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_whale_memory_board',
|
itemId: 'decor_whale_memory_board',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'whale_memory_board',
|
decorId: 'whale_memory_board',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
icon: 'res://assets/ui/mall/furniture/whale_memory_board.png',
|
||||||
name: '鲸语记忆板',
|
name: '鲸语记忆板',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。',
|
description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。',
|
||||||
@@ -109,7 +98,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_whale_tail_lamp',
|
itemId: 'decor_whale_tail_lamp',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'whale_tail_lamp',
|
decorId: 'whale_tail_lamp',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
icon: 'res://assets/ui/mall/furniture/whale_tail_lamp.png',
|
||||||
name: '鲸尾暖灯',
|
name: '鲸尾暖灯',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。',
|
description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。',
|
||||||
@@ -121,7 +110,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_boat_cabin_bed',
|
itemId: 'decor_boat_cabin_bed',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'boat_cabin_bed',
|
decorId: 'boat_cabin_bed',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
icon: 'res://assets/ui/mall/furniture/boat_cabin_bed.png',
|
||||||
name: '船舱小床',
|
name: '船舱小床',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: '白木船舱造型的小床,适合放在个人房间地面区域。',
|
description: '白木船舱造型的小床,适合放在个人房间地面区域。',
|
||||||
@@ -133,7 +122,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_low_wave_bed',
|
itemId: 'decor_low_wave_bed',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'low_wave_bed',
|
decorId: 'low_wave_bed',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
icon: 'res://assets/ui/mall/furniture/low_wave_bed.png',
|
||||||
name: '海浪低床',
|
name: '海浪低床',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。',
|
description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。',
|
||||||
@@ -145,7 +134,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_whale_tail_headboard_bed',
|
itemId: 'decor_whale_tail_headboard_bed',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'whale_tail_headboard_bed',
|
decorId: 'whale_tail_headboard_bed',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
icon: 'res://assets/ui/mall/furniture/whale_tail_headboard_bed.png',
|
||||||
name: '鲸尾床头床',
|
name: '鲸尾床头床',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。',
|
description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。',
|
||||||
@@ -157,7 +146,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_dev_whale_bookshelf',
|
itemId: 'decor_dev_whale_bookshelf',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'dev_whale_bookshelf',
|
decorId: 'dev_whale_bookshelf',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
icon: 'res://assets/ui/mall/furniture/dev_whale_bookshelf.png',
|
||||||
name: '程序员鲸书架',
|
name: '程序员鲸书架',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。',
|
description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。',
|
||||||
@@ -169,7 +158,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_datawhale_bug_feature_badge',
|
itemId: 'decor_datawhale_bug_feature_badge',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'datawhale_bug_feature_badge',
|
decorId: 'datawhale_bug_feature_badge',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
icon: 'res://assets/ui/mall/furniture/datawhale_bug_feature_badge.png',
|
||||||
name: 'BUG特性徽章',
|
name: 'BUG特性徽章',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。',
|
description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。',
|
||||||
@@ -181,7 +170,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_datawhale_buddhist_learning_badge',
|
itemId: 'decor_datawhale_buddhist_learning_badge',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'datawhale_buddhist_learning_badge',
|
decorId: 'datawhale_buddhist_learning_badge',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
icon: 'res://assets/ui/mall/furniture/datawhale_buddhist_learning_badge.png',
|
||||||
name: '佛系学习徽章',
|
name: '佛系学习徽章',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。',
|
description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。',
|
||||||
@@ -193,7 +182,7 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
itemId: 'decor_datawhale_ok_working_badge',
|
itemId: 'decor_datawhale_ok_working_badge',
|
||||||
itemType: 'room_decor',
|
itemType: 'room_decor',
|
||||||
decorId: 'datawhale_ok_working_badge',
|
decorId: 'datawhale_ok_working_badge',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
icon: 'res://assets/ui/mall/furniture/datawhale_ok_working_badge.png',
|
||||||
name: '已经在做徽章',
|
name: '已经在做徽章',
|
||||||
category: 'space',
|
category: 'space',
|
||||||
description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。',
|
description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。',
|
||||||
@@ -201,6 +190,86 @@ export const MALL_ITEMS: MallCatalogItem[] = [
|
|||||||
tags: ['房间家具', '可拖拽', '徽章'],
|
tags: ['房间家具', '可拖拽', '徽章'],
|
||||||
sortOrder: 220,
|
sortOrder: 220,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
itemId: 'decor_low_platform_bed',
|
||||||
|
itemType: 'room_decor',
|
||||||
|
decorId: 'low_platform_bed',
|
||||||
|
name: '航海低平台床',
|
||||||
|
category: 'space',
|
||||||
|
description: '木质低平台床搭配蓝白寝具,购买后可在个人房间自由摆放。',
|
||||||
|
price: 520,
|
||||||
|
tags: [
|
||||||
|
'家具',
|
||||||
|
'可拖拽',
|
||||||
|
'床'
|
||||||
|
],
|
||||||
|
icon: 'res://assets/ui/mall/furniture/low_platform_bed.png',
|
||||||
|
sortOrder: 230
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'decor_low_storage_console',
|
||||||
|
itemType: 'room_decor',
|
||||||
|
decorId: 'low_storage_console',
|
||||||
|
name: '海风矮储物柜',
|
||||||
|
category: 'space',
|
||||||
|
description: '蓝色抽屉与暖木柜面的矮储物柜,购买后可在个人房间自由摆放。',
|
||||||
|
price: 420,
|
||||||
|
tags: [
|
||||||
|
'家具',
|
||||||
|
'可拖拽',
|
||||||
|
'柜子'
|
||||||
|
],
|
||||||
|
icon: 'res://assets/ui/mall/furniture/low_storage_console.png',
|
||||||
|
sortOrder: 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'decor_sea_glass_floor_lamp',
|
||||||
|
itemType: 'room_decor',
|
||||||
|
decorId: 'sea_glass_floor_lamp',
|
||||||
|
name: '海玻璃落地灯',
|
||||||
|
category: 'space',
|
||||||
|
description: '海蓝玻璃灯罩与暖色灯光,为个人房间增添温暖。',
|
||||||
|
price: 340,
|
||||||
|
tags: [
|
||||||
|
'家具',
|
||||||
|
'可拖拽',
|
||||||
|
'灯具'
|
||||||
|
],
|
||||||
|
icon: 'res://assets/ui/mall/furniture/sea_glass_floor_lamp.png',
|
||||||
|
sortOrder: 250
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'decor_tide_chart_worktable',
|
||||||
|
itemType: 'room_decor',
|
||||||
|
decorId: 'tide_chart_worktable',
|
||||||
|
name: '潮汐海图工作台',
|
||||||
|
category: 'space',
|
||||||
|
description: '绘有海图的圆形木质工作台,购买后可在个人房间自由摆放。',
|
||||||
|
price: 480,
|
||||||
|
tags: [
|
||||||
|
'家具',
|
||||||
|
'可拖拽',
|
||||||
|
'桌子'
|
||||||
|
],
|
||||||
|
icon: 'res://assets/ui/mall/furniture/tide_chart_worktable.png',
|
||||||
|
sortOrder: 260
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: 'decor_wave_sea_mat',
|
||||||
|
itemType: 'room_decor',
|
||||||
|
decorId: 'wave_sea_mat',
|
||||||
|
name: '海浪编织地垫',
|
||||||
|
category: 'space',
|
||||||
|
description: '绳编边框与蓝色海浪纹样的地垫,可铺在个人房间地板上。',
|
||||||
|
price: 240,
|
||||||
|
tags: [
|
||||||
|
'家具',
|
||||||
|
'可拖拽',
|
||||||
|
'地面'
|
||||||
|
],
|
||||||
|
icon: 'res://assets/ui/mall/furniture/wave_sea_mat.png',
|
||||||
|
sortOrder: 270
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IsString, Length, Matches } from 'class-validator';
|
|||||||
export class UpdatePlayerAppearanceDto {
|
export class UpdatePlayerAppearanceDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '要穿戴的角色皮肤ID',
|
description: '要穿戴的角色皮肤ID',
|
||||||
example: 'classic_whale',
|
example: 'human_whale_directional_v2_8x4',
|
||||||
})
|
})
|
||||||
@IsString({ message: '皮肤ID必须是字符串' })
|
@IsString({ message: '皮肤ID必须是字符串' })
|
||||||
@Length(1, 100, { message: '皮肤ID长度需在1-100字符之间' })
|
@Length(1, 100, { message: '皮肤ID长度需在1-100字符之间' })
|
||||||
|
|||||||
100
src/business/room_decor/room_decor.service.spec.ts
Normal file
100
src/business/room_decor/room_decor.service.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { RoomDecorService } from './room_decor.service';
|
||||||
|
import { ROOM_DECOR_DEFINITIONS, ROOM_DECOR_LEGACY_SCALES, findRoomDecorDefinition } from './room_decor_catalog';
|
||||||
|
import { MallService } from '../mall/mall.service';
|
||||||
|
|
||||||
|
describe('Furniture purchase and placement', () => {
|
||||||
|
const userId = BigInt(7);
|
||||||
|
let assets: Set<string>;
|
||||||
|
let rows: Map<string, any>;
|
||||||
|
let inventory: any;
|
||||||
|
let placements: any;
|
||||||
|
let room: RoomDecorService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
assets = new Set();
|
||||||
|
rows = new Map();
|
||||||
|
inventory = {
|
||||||
|
listInventory: jest.fn(async () => ({ assets: [], skin_ids: [], room_decor_ids: [...assets] })),
|
||||||
|
hasAsset: jest.fn(async (_user, type, id) => type === 'room_decor' && assets.has(id)),
|
||||||
|
grantAsset: jest.fn(async (_user, _type, id) => assets.add(id)),
|
||||||
|
};
|
||||||
|
placements = {
|
||||||
|
listPlacements: jest.fn(async () => [...rows.values()]),
|
||||||
|
savePlacement: jest.fn(async (_user, row) => { rows.set(row.decor_id, { ...row }); return row; }),
|
||||||
|
};
|
||||||
|
room = new RoomDecorService(placements, inventory);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['low_platform_bed', 'low_storage_console', 'sea_glass_floor_lamp', 'tide_chart_worktable', 'wave_sea_mat'])(
|
||||||
|
'makes %s purchasable, placeable and stable after re-entering', async (decorId) => {
|
||||||
|
let balance = 2000;
|
||||||
|
const wallet = async () => ({ balance, currency: 'whale_coin', user_id: '7' });
|
||||||
|
const spend = jest.fn(async (_user, amount) => { balance -= amount; return wallet(); });
|
||||||
|
const mall = new MallService({ getBalance: wallet } as any, inventory, { getWallet: wallet, spend } as any);
|
||||||
|
const catalog = await mall.getCatalog(userId);
|
||||||
|
const item = catalog.items.find((entry) => entry.decorId === decorId)!;
|
||||||
|
expect(item.status).toBe('available');
|
||||||
|
const purchased = await mall.purchaseItem(userId, item.id);
|
||||||
|
expect(purchased.owned_decor_ids).toContain(decorId);
|
||||||
|
expect(spend).toHaveBeenCalledTimes(1);
|
||||||
|
const definition = findRoomDecorDefinition(decorId)!;
|
||||||
|
const owned = (await room.getInventory(userId)).items[0];
|
||||||
|
expect(owned).toMatchObject({ placed: false, texture: definition.texture, scale: definition.default_scale });
|
||||||
|
await room.savePlacement(userId, { decor_id: decorId, placed: true, position_x: 123, position_y: 45 });
|
||||||
|
expect((await room.getInventory(userId)).items[0]).toMatchObject({
|
||||||
|
placed: true, position_x: 123, position_y: 45, scale: definition.default_scale,
|
||||||
|
});
|
||||||
|
expect((await mall.getCatalog(userId)).items.find((entry) => entry.decorId === decorId)?.status).toBe('owned');
|
||||||
|
await mall.purchaseItem(userId, item.id);
|
||||||
|
expect(spend).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(ROOM_DECOR_DEFINITIONS)('preserves current $decor_id placement through repeated saves', async (definition) => {
|
||||||
|
assets.add(definition.decor_id);
|
||||||
|
let placement: any = {
|
||||||
|
decor_id: definition.decor_id, placed: true,
|
||||||
|
position_x: -137, position_y: 86, scale: definition.default_scale, z_index: definition.default_z_index,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await room.savePlacement(userId, placement);
|
||||||
|
placement = (await room.getInventory(userId)).items[0];
|
||||||
|
expect(placement).toMatchObject({ position_x: -137, position_y: 86, scale: definition.default_scale });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates a legacy bed size without moving it, then stays stable', async () => {
|
||||||
|
assets.add('boat_cabin_bed');
|
||||||
|
rows.set('boat_cabin_bed', { decor_id: 'boat_cabin_bed', placed: true, position_x: -161, position_y: 25, scale: 0.7, z_index: -9 });
|
||||||
|
const item = (await room.getInventory(userId)).items[0];
|
||||||
|
expect(item).toMatchObject({ position_x: -161, position_y: 25, scale: 0.15 });
|
||||||
|
await room.savePlacement(userId, item);
|
||||||
|
expect((await room.getInventory(userId)).items[0]).toEqual(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a custom scale instead of treating all small beds as legacy', async () => {
|
||||||
|
assets.add('boat_cabin_bed');
|
||||||
|
await room.savePlacement(userId, { decor_id: 'boat_cabin_bed', placed: true, position_x: 81, position_y: 36, scale: 0.21 });
|
||||||
|
expect((await room.getInventory(userId)).items[0]).toMatchObject({ position_x: 81, position_y: 36, scale: 0.21 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fits previous artwork scales once and keeps saved room coordinates', async () => {
|
||||||
|
for (const [decorId, scales] of Object.entries(ROOM_DECOR_LEGACY_SCALES)) {
|
||||||
|
assets.clear();
|
||||||
|
rows.clear();
|
||||||
|
assets.add(decorId);
|
||||||
|
for (const scale of scales) {
|
||||||
|
rows.set(decorId, { decor_id: decorId, placed: true, position_x: 81, position_y: 36, scale, z_index: -9 });
|
||||||
|
const fitted = (await room.getInventory(userId)).items[0];
|
||||||
|
expect(fitted).toMatchObject({ position_x: 81, position_y: 36, scale: findRoomDecorDefinition(decorId)!.default_scale });
|
||||||
|
await room.savePlacement(userId, fitted);
|
||||||
|
expect((await room.getInventory(userId)).items[0]).toEqual(fitted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unowned furniture without writing a placement', async () => {
|
||||||
|
await expect(room.savePlacement(userId, { decor_id: 'low_platform_bed', placed: true })).rejects.toThrow('尚未拥有');
|
||||||
|
expect(placements.savePlacement).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,8 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
|||||||
import { InventoryService } from '../player/inventory.service';
|
import { InventoryService } from '../player/inventory.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_DEFINITIONS,
|
ROOM_DECOR_DEFINITIONS,
|
||||||
ROOM_DECOR_LEGACY_DEFAULTS,
|
ROOM_DECOR_LEGACY_SCALES,
|
||||||
ROOM_DECOR_LEGACY_BED_MAX_SCALE,
|
|
||||||
ROOM_DECOR_LEGACY_WALL_DECOR_SCALES,
|
|
||||||
ROOM_DECOR_ROOM_SCALE,
|
|
||||||
findRoomDecorDefinition,
|
findRoomDecorDefinition,
|
||||||
} from './room_decor_catalog';
|
} from './room_decor_catalog';
|
||||||
|
|
||||||
@@ -104,10 +100,11 @@ export class RoomDecorService {
|
|||||||
row: UserRoomDecorRow,
|
row: UserRoomDecorRow,
|
||||||
definition?: { default_scale: number; default_position: { x: number; y: number } },
|
definition?: { default_scale: number; default_position: { x: number; y: number } },
|
||||||
): RoomDecorPayloadPlacement {
|
): RoomDecorPayloadPlacement {
|
||||||
const usesLegacyPlacement = this.usesLegacyPlacement(row);
|
|
||||||
return {
|
return {
|
||||||
position_x: this.normalizedPositionValue(row.position_x, definition?.default_position.x ?? 0, usesLegacyPlacement),
|
// Positions are already room coordinates. Re-scaling them on every read
|
||||||
position_y: this.normalizedPositionValue(row.position_y, definition?.default_position.y ?? 0, usesLegacyPlacement),
|
// makes furniture drift each time a player saves and re-enters the room.
|
||||||
|
position_x: row.position_x ?? definition?.default_position.x ?? 0,
|
||||||
|
position_y: row.position_y ?? definition?.default_position.y ?? 0,
|
||||||
scale: this.normalizedScale(row, definition),
|
scale: this.normalizedScale(row, definition),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,56 +114,12 @@ export class RoomDecorService {
|
|||||||
if (!row.placed && definition) {
|
if (!row.placed && definition) {
|
||||||
return definition.default_scale;
|
return definition.default_scale;
|
||||||
}
|
}
|
||||||
if (this.usesLegacyPlacement(row)) {
|
if (definition && Math.abs(scale - definition.default_scale) <= 0.001) {
|
||||||
return definition?.default_scale ?? scale;
|
return scale;
|
||||||
}
|
}
|
||||||
if (this.isBedDecor(row.decor_id) && scale <= ROOM_DECOR_LEGACY_BED_MAX_SCALE) {
|
const legacyScales = ROOM_DECOR_LEGACY_SCALES[row.decor_id] ?? [];
|
||||||
return ROOM_DECOR_BED_DEFAULT_SCALE;
|
return legacyScales.some((legacy) => Math.abs(scale - legacy) <= 0.001)
|
||||||
}
|
? definition?.default_scale ?? scale
|
||||||
if (row.decor_id === 'whale_floor_rug' && scale >= 0.22 && scale <= 0.30) {
|
: scale;
|
||||||
return definition?.default_scale ?? scale;
|
|
||||||
}
|
|
||||||
if (row.decor_id === 'dev_whale_bookshelf' && (Math.abs(scale - 0.4) <= 0.001 || (scale >= 0.51 && scale <= 0.53))) {
|
|
||||||
// Preserve the old room-fit footprint after switching to the larger mall texture.
|
|
||||||
return definition?.default_scale ?? scale;
|
|
||||||
}
|
|
||||||
if (this.isWallBadgeDecor(row.decor_id) && this.isLegacyWallDecorScale(scale)) {
|
|
||||||
return definition?.default_scale ?? scale;
|
|
||||||
}
|
|
||||||
if (row.decor_id === 'whale_tail_lamp' && scale <= 0.2) {
|
|
||||||
return definition?.default_scale ?? scale;
|
|
||||||
}
|
|
||||||
return scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizedPositionValue(value: number | null, fallback: number, usesLegacyPlacement: boolean) {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return usesLegacyPlacement ? Math.round(value * ROOM_DECOR_ROOM_SCALE) : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private usesLegacyPlacement(row: UserRoomDecorRow) {
|
|
||||||
const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id];
|
|
||||||
if (!legacy) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const scale = row.scale ?? legacy.scale;
|
|
||||||
if (this.isBedDecor(row.decor_id) && scale <= ROOM_DECOR_LEGACY_BED_MAX_SCALE) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return Math.abs(scale - legacy.scale) <= 0.001;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isBedDecor(decorId: string) {
|
|
||||||
return decorId.endsWith('_bed');
|
|
||||||
}
|
|
||||||
|
|
||||||
private isWallBadgeDecor(decorId: string) {
|
|
||||||
return decorId.startsWith('datawhale_') && decorId.endsWith('_badge');
|
|
||||||
}
|
|
||||||
|
|
||||||
private isLegacyWallDecorScale(scale: number) {
|
|
||||||
return ROOM_DECOR_LEGACY_WALL_DECOR_SCALES.some((legacyScale) => Math.abs(scale - legacyScale) <= 0.001);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface RoomDecorDefinition {
|
|||||||
item_id: string;
|
item_id: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
texture?: string;
|
texture?: string;
|
||||||
|
texture_has_shadow?: boolean;
|
||||||
default_scale: number;
|
default_scale: number;
|
||||||
default_position: {
|
default_position: {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -20,174 +21,204 @@ export interface RoomDecorDefinition {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomDecorLegacyDefault {
|
// Keep known historical scales aligned with the frontend RoomDecorCatalog.
|
||||||
scale: number;
|
export const ROOM_DECOR_LEGACY_SCALES: Record<string, number[]> = {
|
||||||
default_position: {
|
whale_floor_rug: [0.42, 1, 0.22, 0.25, 0.28, 0.3, 0.2],
|
||||||
x: number;
|
whale_memory_board: [0.16, 0.11, 0.23],
|
||||||
y: number;
|
whale_tail_lamp: [0.16, 1, 0.19],
|
||||||
};
|
boat_cabin_bed: [1, 0.7, 0.3, 0.35, 0.27],
|
||||||
}
|
low_wave_bed: [1, 0.7, 0.3, 0.35, 0.27],
|
||||||
|
whale_tail_headboard_bed: [1, 0.7, 0.3, 0.35, 0.25],
|
||||||
export const ROOM_DECOR_ROOM_SCALE = 0.7;
|
dev_whale_bookshelf: [1, 0.12, 0.4, 0.52, 0.2],
|
||||||
export const ROOM_DECOR_BED_DEFAULT_SCALE = ROOM_DECOR_ROOM_SCALE;
|
datawhale_bug_feature_badge: [1, 0.7, 0.18, 0.04, 0.055],
|
||||||
export const ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE = 0.12;
|
datawhale_buddhist_learning_badge: [1, 0.7, 0.18, 0.04, 0.055],
|
||||||
export const ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE = 1.0;
|
datawhale_ok_working_badge: [1, 0.7, 0.18, 0.04, 0.055],
|
||||||
export const ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE = 0.04;
|
low_platform_bed: [0.4, 0.22],
|
||||||
export const ROOM_DECOR_LEGACY_BED_MAX_SCALE = 0.35;
|
low_storage_console: [0.32],
|
||||||
export const ROOM_DECOR_LEGACY_WALL_DECOR_SCALES = [0.7, 0.18];
|
sea_glass_floor_lamp: [0.4],
|
||||||
|
tide_chart_worktable: [0.19],
|
||||||
export const ROOM_DECOR_LEGACY_DEFAULTS: Record<string, RoomDecorLegacyDefault> = {
|
wave_sea_mat: [0.4],
|
||||||
whale_floor_rug: {
|
|
||||||
scale: 0.42,
|
|
||||||
default_position: { x: 0, y: 230 },
|
|
||||||
},
|
|
||||||
whale_memory_board: {
|
|
||||||
scale: 0.16,
|
|
||||||
default_position: { x: 260, y: -295 },
|
|
||||||
},
|
|
||||||
whale_tail_lamp: {
|
|
||||||
scale: 0.16,
|
|
||||||
default_position: { x: 330, y: -250 },
|
|
||||||
},
|
|
||||||
boat_cabin_bed: {
|
|
||||||
scale: 1,
|
|
||||||
default_position: { x: -230, y: 35 },
|
|
||||||
},
|
|
||||||
low_wave_bed: {
|
|
||||||
scale: 1,
|
|
||||||
default_position: { x: -140, y: 55 },
|
|
||||||
},
|
|
||||||
whale_tail_headboard_bed: {
|
|
||||||
scale: 1,
|
|
||||||
default_position: { x: 0, y: 45 },
|
|
||||||
},
|
|
||||||
dev_whale_bookshelf: {
|
|
||||||
scale: 1,
|
|
||||||
default_position: { x: -300, y: -55 },
|
|
||||||
},
|
|
||||||
datawhale_bug_feature_badge: {
|
|
||||||
scale: 1,
|
|
||||||
default_position: { x: -300, y: -290 },
|
|
||||||
},
|
|
||||||
datawhale_buddhist_learning_badge: {
|
|
||||||
scale: 1,
|
|
||||||
default_position: { x: 0, y: -290 },
|
|
||||||
},
|
|
||||||
datawhale_ok_working_badge: {
|
|
||||||
scale: 1,
|
|
||||||
default_position: { x: 300, y: -290 },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
||||||
{
|
{
|
||||||
|
texture_has_shadow: true,
|
||||||
decor_id: 'whale_floor_rug',
|
decor_id: 'whale_floor_rug',
|
||||||
item_id: 'decor_whale_floor_rug',
|
|
||||||
name: '鲸浪地毯',
|
name: '鲸浪地毯',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
icon: 'res://assets/ui/mall/furniture/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_room_reference_v1.png',
|
||||||
default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE,
|
default_position: { x: 0, y: 100 },
|
||||||
default_position: { x: 0, y: 161 },
|
default_scale: 0.14,
|
||||||
default_z_index: -8,
|
default_z_index: -8,
|
||||||
|
item_id: 'decor_whale_floor_rug',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'whale_memory_board',
|
decor_id: 'whale_memory_board',
|
||||||
item_id: 'decor_whale_memory_board',
|
|
||||||
name: '鲸语记忆板',
|
name: '鲸语记忆板',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
icon: 'res://assets/ui/mall/furniture/whale_memory_board.png',
|
||||||
default_scale: 0.11,
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_memory_board_room_reference_v1.png',
|
||||||
default_position: { x: 182, y: -207 },
|
default_position: { x: 190, y: -235 },
|
||||||
|
default_scale: 0.12,
|
||||||
default_z_index: -14,
|
default_z_index: -14,
|
||||||
|
item_id: 'decor_whale_memory_board',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'whale_tail_lamp',
|
decor_id: 'whale_tail_lamp',
|
||||||
item_id: 'decor_whale_tail_lamp',
|
|
||||||
name: '鲸尾暖灯',
|
name: '鲸尾暖灯',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
icon: 'res://assets/ui/mall/furniture/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_room_reference_v1.png',
|
||||||
default_scale: 1,
|
texture_has_shadow: true,
|
||||||
default_position: { x: 231, y: -175 },
|
default_position: { x: 20, y: -10 },
|
||||||
default_z_index: -10,
|
default_scale: 0.09,
|
||||||
collision_size: { x: 50, y: 32 },
|
default_z_index: -8,
|
||||||
collision_offset: { x: 0, y: 56 },
|
collision_size: { x: 245, y: 110 },
|
||||||
|
collision_offset: { x: 0, y: 327 },
|
||||||
|
item_id: 'decor_whale_tail_lamp',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
texture_has_shadow: true,
|
||||||
decor_id: 'boat_cabin_bed',
|
decor_id: 'boat_cabin_bed',
|
||||||
item_id: 'decor_boat_cabin_bed',
|
|
||||||
name: '船舱小床',
|
name: '船舱小床',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
icon: 'res://assets/ui/mall/furniture/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_room_reference_v1.png',
|
||||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
default_position: { x: -180, y: 10 },
|
||||||
default_position: { x: -161, y: 25 },
|
default_scale: 0.15,
|
||||||
default_z_index: -9,
|
default_z_index: -9,
|
||||||
collision_size: { x: 220, y: 112 },
|
collision_size: { x: 560, y: 520 },
|
||||||
collision_offset: { x: 0, y: 52 },
|
collision_offset: { x: 0, y: 95 },
|
||||||
|
item_id: 'decor_boat_cabin_bed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'low_wave_bed',
|
decor_id: 'low_wave_bed',
|
||||||
item_id: 'decor_low_wave_bed',
|
|
||||||
name: '海浪低床',
|
name: '海浪低床',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
icon: 'res://assets/ui/mall/furniture/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_room_reference_v1.png',
|
||||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
texture_has_shadow: true,
|
||||||
default_position: { x: -98, y: 39 },
|
default_position: { x: -180, y: 10 },
|
||||||
|
default_scale: 0.15,
|
||||||
default_z_index: -9,
|
default_z_index: -9,
|
||||||
collision_size: { x: 220, y: 112 },
|
collision_size: { x: 560, y: 520 },
|
||||||
collision_offset: { x: 0, y: 56 },
|
collision_offset: { x: 0, y: 95 },
|
||||||
|
item_id: 'decor_low_wave_bed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
texture_has_shadow: true,
|
||||||
decor_id: 'whale_tail_headboard_bed',
|
decor_id: 'whale_tail_headboard_bed',
|
||||||
item_id: 'decor_whale_tail_headboard_bed',
|
|
||||||
name: '鲸尾床头床',
|
name: '鲸尾床头床',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
icon: 'res://assets/ui/mall/furniture/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_room_reference_v1.png',
|
||||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
default_position: { x: 180, y: 10 },
|
||||||
default_position: { x: 0, y: 32 },
|
default_scale: 0.16,
|
||||||
default_z_index: -9,
|
default_z_index: -9,
|
||||||
collision_size: { x: 214, y: 112 },
|
collision_size: { x: 540, y: 520 },
|
||||||
collision_offset: { x: 0, y: 62 },
|
collision_offset: { x: 0, y: 95 },
|
||||||
|
item_id: 'decor_whale_tail_headboard_bed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'dev_whale_bookshelf',
|
decor_id: 'dev_whale_bookshelf',
|
||||||
item_id: 'decor_dev_whale_bookshelf',
|
|
||||||
name: '程序员鲸书架',
|
name: '程序员鲸书架',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
icon: 'res://assets/ui/mall/furniture/dev_whale_bookshelf.png',
|
||||||
texture: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_dev_whale_bookshelf_room_reference_v1.png',
|
||||||
default_scale: ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE,
|
texture_has_shadow: true,
|
||||||
default_position: { x: -210, y: -39 },
|
default_position: { x: -195, y: -190 },
|
||||||
|
default_scale: 0.125,
|
||||||
default_z_index: -10,
|
default_z_index: -10,
|
||||||
collision_size: { x: 626.667, y: 226.667 },
|
collision_size: { x: 790, y: 180 },
|
||||||
collision_offset: { x: 0, y: 580 },
|
collision_offset: { x: 0, y: 270 },
|
||||||
|
item_id: 'decor_dev_whale_bookshelf',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'datawhale_bug_feature_badge',
|
decor_id: 'datawhale_bug_feature_badge',
|
||||||
item_id: 'decor_datawhale_bug_feature_badge',
|
|
||||||
name: 'BUG特性徽章',
|
name: 'BUG特性徽章',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
icon: 'res://assets/ui/mall/furniture/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_room_reference_v1.png',
|
||||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
default_position: { x: -220, y: -204 },
|
||||||
default_position: { x: -210, y: -203 },
|
default_scale: 0.033,
|
||||||
default_z_index: -14,
|
default_z_index: -14,
|
||||||
|
item_id: 'decor_datawhale_bug_feature_badge',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'datawhale_buddhist_learning_badge',
|
decor_id: 'datawhale_buddhist_learning_badge',
|
||||||
item_id: 'decor_datawhale_buddhist_learning_badge',
|
|
||||||
name: '佛系学习徽章',
|
name: '佛系学习徽章',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
icon: 'res://assets/ui/mall/furniture/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_room_reference_v1.png',
|
||||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
default_position: { x: 0, y: -300 },
|
||||||
default_position: { x: 0, y: -203 },
|
default_scale: 0.033,
|
||||||
default_z_index: -14,
|
default_z_index: -14,
|
||||||
|
item_id: 'decor_datawhale_buddhist_learning_badge',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
decor_id: 'datawhale_ok_working_badge',
|
decor_id: 'datawhale_ok_working_badge',
|
||||||
item_id: 'decor_datawhale_ok_working_badge',
|
|
||||||
name: '已经在做徽章',
|
name: '已经在做徽章',
|
||||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
icon: 'res://assets/ui/mall/furniture/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_room_reference_v1.png',
|
||||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
default_position: { x: 220, y: -204 },
|
||||||
default_position: { x: 210, y: -203 },
|
default_scale: 0.033,
|
||||||
default_z_index: -14,
|
default_z_index: -14,
|
||||||
|
item_id: 'decor_datawhale_ok_working_badge',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decor_id: 'low_platform_bed',
|
||||||
|
name: '航海低平台床',
|
||||||
|
icon: 'res://assets/ui/mall/furniture/low_platform_bed.png',
|
||||||
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_platform_bed_roomfit.png',
|
||||||
|
texture_has_shadow: true,
|
||||||
|
default_position: { x: -180, y: 20 },
|
||||||
|
default_scale: 0.3,
|
||||||
|
default_z_index: -9,
|
||||||
|
collision_size: { x: 360, y: 170 },
|
||||||
|
collision_offset: { x: 0, y: 100 },
|
||||||
|
item_id: 'decor_low_platform_bed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decor_id: 'low_storage_console',
|
||||||
|
name: '海风矮储物柜',
|
||||||
|
icon: 'res://assets/ui/mall/furniture/low_storage_console.png',
|
||||||
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_storage_console_roomfit.png',
|
||||||
|
texture_has_shadow: true,
|
||||||
|
default_position: { x: 190, y: -160 },
|
||||||
|
default_scale: 0.19,
|
||||||
|
default_z_index: -10,
|
||||||
|
collision_size: { x: 500, y: 65 },
|
||||||
|
collision_offset: { x: 0, y: 115 },
|
||||||
|
item_id: 'decor_low_storage_console',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decor_id: 'sea_glass_floor_lamp',
|
||||||
|
name: '海玻璃落地灯',
|
||||||
|
icon: 'res://assets/ui/mall/furniture/sea_glass_floor_lamp.png',
|
||||||
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_sea_glass_floor_lamp_roomfit.png',
|
||||||
|
texture_has_shadow: true,
|
||||||
|
default_position: { x: 300, y: -30 },
|
||||||
|
default_scale: 0.22,
|
||||||
|
default_z_index: -8,
|
||||||
|
collision_size: { x: 150, y: 70 },
|
||||||
|
collision_offset: { x: 0, y: 145 },
|
||||||
|
item_id: 'decor_sea_glass_floor_lamp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decor_id: 'tide_chart_worktable',
|
||||||
|
name: '潮汐海图工作台',
|
||||||
|
icon: 'res://assets/ui/mall/furniture/tide_chart_worktable.png',
|
||||||
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_tide_chart_worktable_roomfit.png',
|
||||||
|
texture_has_shadow: true,
|
||||||
|
default_position: { x: 130, y: 70 },
|
||||||
|
default_scale: 0.1,
|
||||||
|
default_z_index: -9,
|
||||||
|
collision_size: { x: 850, y: 180 },
|
||||||
|
collision_offset: { x: 0, y: 250 },
|
||||||
|
item_id: 'decor_tide_chart_worktable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decor_id: 'wave_sea_mat',
|
||||||
|
name: '海浪编织地垫',
|
||||||
|
icon: 'res://assets/ui/mall/furniture/wave_sea_mat.png',
|
||||||
|
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_wave_sea_mat_roomfit.png',
|
||||||
|
texture_has_shadow: true,
|
||||||
|
default_position: { x: 0, y: 115 },
|
||||||
|
default_scale: 0.24,
|
||||||
|
default_z_index: -8,
|
||||||
|
item_id: 'decor_wave_sea_mat',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class SkinGenerationService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createJob(userId: bigint, dto: CreateSkinGenerationJobDto): Promise<SkinGenerationJobResponse> {
|
async createJob(userId: bigint, dto: CreateSkinGenerationJobDto): Promise<SkinGenerationJobResponse> {
|
||||||
|
this.accountProfileService.assertCustomSkinCreationAvailable();
|
||||||
const apiKey = this.configService.get<string>('NOVAMAILIO_API_KEY') || process.env.NOVAMAILIO_API_KEY;
|
const apiKey = this.configService.get<string>('NOVAMAILIO_API_KEY') || process.env.NOVAMAILIO_API_KEY;
|
||||||
if (!apiKey || apiKey.trim().length === 0) {
|
if (!apiKey || apiKey.trim().length === 0) {
|
||||||
throw new BadRequestException('服务端尚未配置 NOVAMAILIO_API_KEY,无法生成角色皮肤');
|
throw new BadRequestException('服务端尚未配置 NOVAMAILIO_API_KEY,无法生成角色皮肤');
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const WORLD_NPC_DEFINITIONS: readonly WorldNpcDefinition[] = [
|
|||||||
dailyFocus: '巡视码头与广场,收集水路消息并帮助居民',
|
dailyFocus: '巡视码头与广场,收集水路消息并帮助居民',
|
||||||
homeLocationId: 'square_dock_guide',
|
homeLocationId: 'square_dock_guide',
|
||||||
stationary: true,
|
stationary: true,
|
||||||
fixedPosition: { x: -825, y: 437 },
|
fixedPosition: { x: -825, y: 475 },
|
||||||
scene: 'dock_crayfish',
|
scene: 'dock_crayfish',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export const WORLD_LOCATIONS: readonly WorldLocation[] = [
|
|||||||
{
|
{
|
||||||
id: 'square_dock_guide', mapId: 'whale_port', name: '码头向导岗', x: -720, y: 437,
|
id: 'square_dock_guide', mapId: 'whale_port', name: '码头向导岗', x: -720, y: 437,
|
||||||
tags: ['organize', 'socialize', 'reflect'],
|
tags: ['organize', 'socialize', 'reflect'],
|
||||||
slots: [{ x: -900, y: 437 }, { x: -780, y: 437 }, { x: -660, y: 437 }, { x: -540, y: 437 }],
|
// Approach the inland path below the dock's southeast mooring post.
|
||||||
|
slots: [{ x: -900, y: 480 }, { x: -780, y: 437 }, { x: -660, y: 437 }, { x: -540, y: 437 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'square_dock_research', mapId: 'whale_port', name: '广场海边研究点', x: -400, y: -180,
|
id: 'square_dock_research', mapId: 'whale_port', name: '广场海边研究点', x: -400, y: -180,
|
||||||
@@ -28,10 +29,9 @@ export const WORLD_LOCATIONS: readonly WorldLocation[] = [
|
|||||||
{
|
{
|
||||||
id: 'square_forum', mapId: 'whale_port', name: '广场交流区', x: 0, y: -280,
|
id: 'square_forum', mapId: 'whale_port', name: '广场交流区', x: 0, y: -280,
|
||||||
tags: ['socialize', 'share'],
|
tags: ['socialize', 'share'],
|
||||||
// Keep every visual standing point clear of the fountain footprint. The
|
// All slots connect directly to the north walkway. Keep them on this side
|
||||||
// last point is used by Niulai; -150 is too close once his sprite height
|
// of the fountain so entering or leaving a slot never crosses the basin.
|
||||||
// and ground anchor are accounted for.
|
slots: [{ x: 0, y: -430 }, { x: 0, y: -340 }, { x: 160, y: -380 }, { x: 0, y: -240 }],
|
||||||
slots: [{ x: 0, y: -430 }, { x: 0, y: -340 }, { x: 0, y: 325 }, { x: 0, y: -240 }],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'square_notice_board', mapId: 'whale_port', name: '广场公告栏', x: -520, y: 480,
|
id: 'square_notice_board', mapId: 'whale_port', name: '广场公告栏', x: -520, y: 480,
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export class CreateUserProfileDto {
|
|||||||
*/
|
*/
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '角色皮肤ID',
|
description: '角色皮肤ID',
|
||||||
example: 'classic_whale',
|
example: 'human_whale_directional_v2_8x4',
|
||||||
maxLength: 100
|
maxLength: 100
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export class RegisterDto {
|
|||||||
*/
|
*/
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '初始角色皮肤ID(可选)',
|
description: '初始角色皮肤ID(可选)',
|
||||||
example: 'classic_whale',
|
example: 'human_whale_directional_v2_8x4',
|
||||||
required: false,
|
required: false,
|
||||||
maxLength: 100
|
maxLength: 100
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user