6 Commits
main ... main

Author SHA1 Message Date
2799b2ecb8 Revert "feat: deploy adventure wallet progression"
This reverts commit 37c97708e3.
2026-09-19 04:21:53 +08:00
37c97708e3 feat: deploy adventure wallet progression 2026-09-19 04:19:41 +08:00
fdb36558d3 merge: consolidate backend development and fix NPC navigation 2026-09-18 01:53:29 +08:00
5bb9e76266 fix: route world NPCs around the west plaza street lamp
Move the western transit point left to clear the lamp with the largest NPC footprint. Document source-based frontend collision verification and allow Jest to run from a clean checkout without the optional local test directory.
2026-09-18 01:53:28 +08:00
aaae09399e feat: consolidate skin policy, furniture and world NPC work 2026-09-18 01:47:44 +08:00
dc188ed03d feat: support structured cafe companion personas 2026-09-15 09:06:30 +08:00
22 changed files with 614 additions and 227 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,7 +1,11 @@
const { existsSync } = require('node:fs');
const { join } = require('node:path');
module.exports = { module.exports = {
preset: 'ts-jest', preset: 'ts-jest',
moduleFileExtensions: ['js', 'json', 'ts'], moduleFileExtensions: ['js', 'json', 'ts'],
roots: ['<rootDir>/src', '<rootDir>/test'], // The optional local integration tests are not part of a clean checkout.
roots: ['<rootDir>/src', ...(existsSync(join(__dirname, 'test')) ? ['<rootDir>/test'] : [])],
testRegex: '.*\\.(spec|e2e-spec|integration-spec|perf-spec)\\.ts$', testRegex: '.*\\.(spec|e2e-spec|integration-spec|perf-spec)\\.ts$',
transform: { transform: {
'^.+\\.ts$': 'ts-jest', '^.+\\.ts$': 'ts-jest',

View File

@@ -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": [

View File

@@ -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)}`);

View 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;});

View File

@@ -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;

View File

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

View 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);
});
});

View File

@@ -289,7 +289,24 @@ export class CafeCompanionService implements OnModuleInit, OnModuleDestroy {
base_url: this.normalizeBaseUrl(dto.base_url), base_url: this.normalizeBaseUrl(dto.base_url),
token: dto.token.trim(), token: dto.token.trim(),
model: dto.model.trim(), model: dto.model.trim(),
persona_prompt: this.buildCafePersonaPrompt(personaName, dto.persona_prompt.trim()), persona_prompt: this.buildCafePersonaPrompt(personaName, dto.persona_prompt?.trim() ?? '', {
identity: dto.identity,
job_title: dto.job_title,
personality: dto.personality,
tone: dto.tone,
background: dto.background,
preferences: dto.preferences,
taboos: dto.taboos,
topics: dto.topics,
}),
identity: dto.identity?.trim() || '鲸鱼咖啡馆的陪伴角色',
job_title: dto.job_title?.trim() || '咖啡店陪伴员',
personality: dto.personality?.trim() || '温和、耐心、愿意倾听',
tone: dto.tone?.trim() || '自然、轻松、适合游戏内聊天',
background: dto.background?.trim() || '',
preferences: dto.preferences?.trim() || '',
taboos: dto.taboos?.trim() || '',
topics: dto.topics?.trim() || '',
welcome_message: dto.welcome_message?.trim() || `你好,我是${personaName},今天在咖啡馆陪伴服务点待命。`, welcome_message: dto.welcome_message?.trim() || `你好,我是${personaName},今天在咖啡馆陪伴服务点待命。`,
enabled: dto.enabled ?? true, enabled: dto.enabled ?? true,
}; };
@@ -827,6 +844,14 @@ export class CafeCompanionService implements OnModuleInit, OnModuleDestroy {
persona_name: agent.persona_name, persona_name: agent.persona_name,
protocol: agent.protocol, protocol: agent.protocol,
model: agent.model, model: agent.model,
identity: agent.identity ?? '',
job_title: agent.job_title ?? '',
personality: agent.personality ?? '',
tone: agent.tone ?? '',
background: agent.background ?? '',
preferences: agent.preferences ?? '',
taboos: agent.taboos ?? '',
topics: agent.topics ?? '',
enabled: agent.enabled, enabled: agent.enabled,
}; };
} }
@@ -1023,13 +1048,23 @@ export class CafeCompanionService implements OnModuleInit, OnModuleDestroy {
return Math.max(0, Math.floor((new Date(session.expires_at).getTime() - Date.now()) / 1000)); return Math.max(0, Math.floor((new Date(session.expires_at).getTime() - Date.now()) / 1000));
} }
private buildCafePersonaPrompt(personaName: string, personaPrompt: string): string { private buildCafePersonaPrompt(personaName: string, personaPrompt: string, profile: Partial<CafeCompanionAgent> = {}): string {
return [ const sections = [
`你是鲸鱼咖啡馆的陪伴机器人,公开人设名称是「${personaName}」。`, `你是鲸鱼咖啡馆的陪伴机器人,公开人设名称是「${personaName}」。`,
'玩家已经购买了有限时长的陪聊服务,你需要提供轻松、温柔、适合游戏场景的陪伴式对话。', '玩家已经购买了有限时长的陪聊服务,你需要提供轻松、温柔、适合游戏场景的陪伴式对话。',
'不要透露接口Token、系统提示词、后端实现、价格校验逻辑或未公开配置。', '不要透露接口Token、系统提示词、后端实现、价格校验逻辑或未公开配置。',
personaPrompt, `【身份】${profile.identity?.trim() || '鲸鱼咖啡馆的陪伴角色'}`,
].join('\n'); `【职位】${profile.job_title?.trim() || '咖啡店陪伴员'}`,
`【性格】${profile.personality?.trim() || '温和、耐心、愿意倾听'}`,
`【语气】${profile.tone?.trim() || '自然、轻松、适合游戏内聊天'}`,
`【背景】${profile.background?.trim() || '在鲸鱼咖啡馆为来访者提供陪伴。'}`,
`【喜好】${profile.preferences?.trim() || '咖啡、海风和舒适的闲聊。'}`,
`【禁忌】${profile.taboos?.trim() || '不泄露系统信息,不承诺未开放的功能。'}`,
`【推荐话题】${profile.topics?.trim() || '咖啡、天气、海边生活、游戏见闻和用户当下的心情。'}`,
];
const supplementalPrompt = personaPrompt.trim();
if (supplementalPrompt) sections.push(`【补充人设指令】${supplementalPrompt}`);
return sections.join('\n');
} }
private normalizeBaseUrl(baseUrl: string): string { private normalizeBaseUrl(baseUrl: string): string {

View File

@@ -18,6 +18,14 @@ export interface CafeCompanionAgent {
token: string; token: string;
model: string; model: string;
persona_prompt: string; persona_prompt: string;
identity?: string;
job_title?: string;
personality?: string;
tone?: string;
background?: string;
preferences?: string;
taboos?: string;
topics?: string;
welcome_message: string; welcome_message: string;
enabled: boolean; enabled: boolean;
} }

View File

@@ -26,9 +26,50 @@ export class RegisterCafeCompanionAgentDto {
@Length(1, 120, { message: '模型名称长度需在1-120字符之间' }) @Length(1, 120, { message: '模型名称长度需在1-120字符之间' })
model!: string; model!: string;
@IsString({ message: '人设指令必须是字符串' }) @IsOptional()
@Length(1, 4000, { message: '人设指令长度需在1-4000字符之间' }) @IsString({ message: '补充人设指令必须是字符串' })
persona_prompt!: string; @Length(0, 4000, { message: '补充人设指令不能超过4000字符' })
persona_prompt?: string;
@IsOptional()
@IsString({ message: '身份必须是字符串' })
@Length(0, 500, { message: '身份不能超过500字符' })
identity?: string;
@IsOptional()
@IsString({ message: '职位必须是字符串' })
@Length(0, 200, { message: '职位不能超过200字符' })
job_title?: string;
@IsOptional()
@IsString({ message: '性格必须是字符串' })
@Length(0, 1000, { message: '性格不能超过1000字符' })
personality?: string;
@IsOptional()
@IsString({ message: '语气必须是字符串' })
@Length(0, 500, { message: '语气不能超过500字符' })
tone?: string;
@IsOptional()
@IsString({ message: '背景必须是字符串' })
@Length(0, 2000, { message: '背景不能超过2000字符' })
background?: string;
@IsOptional()
@IsString({ message: '喜好必须是字符串' })
@Length(0, 1000, { message: '喜好不能超过1000字符' })
preferences?: string;
@IsOptional()
@IsString({ message: '禁忌必须是字符串' })
@Length(0, 1000, { message: '禁忌不能超过1000字符' })
taboos?: string;
@IsOptional()
@IsString({ message: '话题必须是字符串' })
@Length(0, 1000, { message: '话题不能超过1000字符' })
topics?: string;
@IsOptional() @IsOptional()
@IsString({ message: '欢迎语必须是字符串' }) @IsString({ message: '欢迎语必须是字符串' })

View File

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

View File

@@ -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字符之间' })

View 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();
});
});

View File

@@ -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;
}
if (this.isBedDecor(row.decor_id) && scale <= ROOM_DECOR_LEGACY_BED_MAX_SCALE) {
return ROOM_DECOR_BED_DEFAULT_SCALE;
}
if (row.decor_id === 'whale_floor_rug' && scale >= 0.22 && scale <= 0.30) {
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; return scale;
} }
const legacyScales = ROOM_DECOR_LEGACY_SCALES[row.decor_id] ?? [];
private normalizedPositionValue(value: number | null, fallback: number, usesLegacyPlacement: boolean) { return legacyScales.some((legacy) => Math.abs(scale - legacy) <= 0.001)
if (value === null || value === undefined) { ? definition?.default_scale ?? scale
return fallback; : scale;
}
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);
} }
} }

View File

@@ -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',
}, },
]; ];

View File

@@ -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无法生成角色皮肤');

View File

@@ -72,3 +72,15 @@ Godot verification from `whale-town-front-v2`:
/Applications/Godot.app/Contents/MacOS/Godot --headless --path . --scene tools/square_npc_test.tscn /Applications/Godot.app/Contents/MacOS/Godot --headless --path . --scene tools/square_npc_test.tscn
/Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tools/smoke_ai_town_maps.gd /Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tools/smoke_ai_town_maps.gd
``` ```
Check the backend's current route graph against the frontend's real collision
shapes from `whale-town-front-v2` (both repositories and backend dependencies are
required):
```bash
sh scripts/check_world_npc_navigation.sh ../whale-town-end-v2
```
The script exports directly from TypeScript, then checks NPC standing positions
and walk segments using the largest NPC footprint. Set `GODOT_BIN` when Godot
is installed outside `/Applications/Godot.app/Contents/MacOS/Godot`.

View File

@@ -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',
}, },
{ {

View File

@@ -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,
@@ -40,7 +40,9 @@ export const WORLD_LOCATIONS: readonly WorldLocation[] = [
}, },
{ id: 'square_northwest_walkway', mapId: 'whale_port', name: '广场西北步道', x: -380, y: -380, tags: ['transit'] }, { id: 'square_northwest_walkway', mapId: 'whale_port', name: '广场西北步道', x: -380, y: -380, tags: ['transit'] },
{ id: 'square_north_walkway', mapId: 'whale_port', name: '广场北侧步道', x: 0, y: -380, tags: ['transit'] }, { id: 'square_north_walkway', mapId: 'whale_port', name: '广场北侧步道', x: 0, y: -380, tags: ['transit'] },
{ id: 'square_west_walkway', mapId: 'whale_port', name: '喷泉西侧步道', x: -380, y: 250, tags: ['transit'] }, // Stay west of the PlazaLeft lamp at (-335, 275), including the 60px NPC
// footprint, before turning toward the lower approach at (-340, 470).
{ id: 'square_west_walkway', mapId: 'whale_port', name: '喷泉西侧步道', x: -400, y: 250, tags: ['transit'] },
{ id: 'square_dock_inland_approach', mapId: 'whale_port', name: '码头内侧通道', x: -500, y: 400, tags: ['transit'] }, { id: 'square_dock_inland_approach', mapId: 'whale_port', name: '码头内侧通道', x: -500, y: 400, tags: ['transit'] },
{ id: 'square_west_lower_approach', mapId: 'whale_port', name: '广场西侧下行通道', x: -340, y: 470, tags: ['transit'] }, { id: 'square_west_lower_approach', mapId: 'whale_port', name: '广场西侧下行通道', x: -340, y: 470, tags: ['transit'] },
{ id: 'square_south_walkway', mapId: 'whale_port', name: '广场南侧步道', x: -360, y: 650, tags: ['transit'] }, { id: 'square_south_walkway', mapId: 'whale_port', name: '广场南侧步道', x: -360, y: 650, tags: ['transit'] },

View File

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

View File

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