diff --git a/.env.local b/.env.local index bb3b6abd..a7ccaac6 100644 --- a/.env.local +++ b/.env.local @@ -11,3 +11,6 @@ WECHAT_AUTH_PROVIDER="mock" SMS_AUTH_ENABLED="true" SMS_AUTH_PROVIDER="mock" +SMS_AUTH_MOCK_VERIFY_CODE="123456" + +VITE_AUTH_ALLOW_DEV_GUEST="false" diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index e37f6283..c0ea586f 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -34,14 +34,64 @@ export type BasicOkResult = { ok: true; }; +export type CustomWorldPublicationStatus = 'draft' | 'published'; +export type CustomWorldThemeMode = + | 'martial' + | 'arcane' + | 'machina' + | 'tide' + | 'rift' + | 'mythic'; + export type CustomWorldProfileRecord = JsonObject & { id?: string; }; +export type CustomWorldLibraryEntry< + TProfile = CustomWorldProfileRecord, +> = { + ownerUserId: string; + profileId: string; + profile: TProfile; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; + updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldThemeMode; + playableNpcCount: number; + landmarkCount: number; +}; + +export type CustomWorldGalleryCard = Omit< + CustomWorldLibraryEntry, + 'profile' +>; + export type CustomWorldLibraryResponse< TProfile = CustomWorldProfileRecord, > = { - profiles: TProfile[]; + entries: CustomWorldLibraryEntry[]; +}; + +export type CustomWorldLibraryMutationResponse< + TProfile = CustomWorldProfileRecord, +> = { + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; +}; + +export type CustomWorldGalleryResponse = { + entries: CustomWorldGalleryCard[]; +}; + +export type CustomWorldGalleryDetailResponse< + TProfile = CustomWorldProfileRecord, +> = { + entry: CustomWorldLibraryEntry; }; export const CUSTOM_WORLD_GENERATION_MODES = ['fast', 'full'] as const; diff --git a/server-node/sql/schema/00_schema_migrations.sql b/server-node/sql/schema/00_schema_migrations.sql new file mode 100644 index 00000000..70e5a638 --- /dev/null +++ b/server-node/sql/schema/00_schema_migrations.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL +); diff --git a/server-node/sql/schema/01_users.sql b/server-node/sql/schema/01_users.sql new file mode 100644 index 00000000..71aa0f93 --- /dev/null +++ b/server-node/sql/schema/01_users.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + token_version INTEGER NOT NULL DEFAULT 1, + display_name TEXT NOT NULL, + login_provider TEXT NOT NULL DEFAULT 'password', + account_status TEXT NOT NULL DEFAULT 'active', + phone_number TEXT, + phone_verified_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_phone_number_unique_idx + ON users (phone_number); diff --git a/server-node/sql/schema/02_save_snapshots.sql b/server-node/sql/schema/02_save_snapshots.sql new file mode 100644 index 00000000..75a4c486 --- /dev/null +++ b/server-node/sql/schema/02_save_snapshots.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS save_snapshots ( + user_id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + saved_at TEXT NOT NULL, + bottom_tab TEXT NOT NULL, + game_state_json JSONB NOT NULL, + current_story_json JSONB, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/server-node/sql/schema/03_runtime_settings.sql b/server-node/sql/schema/03_runtime_settings.sql new file mode 100644 index 00000000..1d5a59e0 --- /dev/null +++ b/server-node/sql/schema/03_runtime_settings.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS runtime_settings ( + user_id TEXT PRIMARY KEY, + music_volume REAL NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/server-node/sql/schema/04_custom_world_profiles.sql b/server-node/sql/schema/04_custom_world_profiles.sql new file mode 100644 index 00000000..632db82c --- /dev/null +++ b/server-node/sql/schema/04_custom_world_profiles.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS custom_world_profiles ( + user_id TEXT NOT NULL, + profile_id TEXT NOT NULL, + payload_json JSONB NOT NULL, + visibility TEXT NOT NULL DEFAULT 'draft', + published_at TEXT, + author_display_name TEXT NOT NULL DEFAULT '玩家', + world_name TEXT NOT NULL DEFAULT '', + subtitle TEXT NOT NULL DEFAULT '', + summary_text TEXT NOT NULL DEFAULT '', + cover_image_src TEXT, + theme_mode TEXT NOT NULL DEFAULT 'mythic', + playable_npc_count INTEGER NOT NULL DEFAULT 0, + landmark_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, profile_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS custom_world_profiles_user_updated_idx + ON custom_world_profiles (user_id, updated_at DESC); + +CREATE INDEX IF NOT EXISTS custom_world_profiles_published_idx + ON custom_world_profiles (visibility, published_at DESC, updated_at DESC); diff --git a/server-node/sql/schema/05_auth_identities.sql b/server-node/sql/schema/05_auth_identities.sql new file mode 100644 index 00000000..21854d7b --- /dev/null +++ b/server-node/sql/schema/05_auth_identities.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS auth_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + provider_uid TEXT NOT NULL, + provider_unionid TEXT, + display_name TEXT, + avatar_url TEXT, + is_verified BOOLEAN NOT NULL DEFAULT TRUE, + meta_json JSONB, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_uid_unique_idx + ON auth_identities (provider, provider_uid); + +CREATE UNIQUE INDEX IF NOT EXISTS auth_identities_provider_unionid_unique_idx + ON auth_identities (provider, provider_unionid) + WHERE provider_unionid IS NOT NULL; + +CREATE INDEX IF NOT EXISTS auth_identities_user_idx + ON auth_identities (user_id, provider); diff --git a/server-node/sql/schema/06_user_sessions.sql b/server-node/sql/schema/06_user_sessions.sql new file mode 100644 index 00000000..e980dbe3 --- /dev/null +++ b/server-node/sql/schema/06_user_sessions.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + refresh_token_hash TEXT NOT NULL UNIQUE, + client_type TEXT NOT NULL, + user_agent TEXT, + ip TEXT, + expires_at TEXT NOT NULL, + revoked_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS user_sessions_user_idx + ON user_sessions (user_id, expires_at DESC); diff --git a/server-node/sql/schema/07_auth_audit_logs.sql b/server-node/sql/schema/07_auth_audit_logs.sql new file mode 100644 index 00000000..5e7e9694 --- /dev/null +++ b/server-node/sql/schema/07_auth_audit_logs.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS auth_audit_logs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, + detail TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + meta_json JSONB, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS auth_audit_logs_user_created_idx + ON auth_audit_logs (user_id, created_at DESC); diff --git a/server-node/sql/schema/08_sms_auth_events.sql b/server-node/sql/schema/08_sms_auth_events.sql new file mode 100644 index 00000000..adaf19a6 --- /dev/null +++ b/server-node/sql/schema/08_sms_auth_events.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS sms_auth_events ( + id TEXT PRIMARY KEY, + phone_number TEXT NOT NULL, + scene TEXT NOT NULL, + action TEXT NOT NULL, + success BOOLEAN NOT NULL, + ip TEXT, + user_agent TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS sms_auth_events_phone_created_idx + ON sms_auth_events (phone_number, created_at DESC); + +CREATE INDEX IF NOT EXISTS sms_auth_events_ip_created_idx + ON sms_auth_events (ip, created_at DESC); diff --git a/server-node/sql/schema/09_auth_risk_blocks.sql b/server-node/sql/schema/09_auth_risk_blocks.sql new file mode 100644 index 00000000..0cdbc4e2 --- /dev/null +++ b/server-node/sql/schema/09_auth_risk_blocks.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS auth_risk_blocks ( + id TEXT PRIMARY KEY, + scope_type TEXT NOT NULL, + scope_key TEXT NOT NULL, + reason TEXT NOT NULL, + expires_at TEXT NOT NULL, + lifted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS auth_risk_blocks_scope_idx + ON auth_risk_blocks (scope_type, scope_key, expires_at DESC); diff --git a/server-node/sql/schema/README.md b/server-node/sql/schema/README.md new file mode 100644 index 00000000..68c6c038 --- /dev/null +++ b/server-node/sql/schema/README.md @@ -0,0 +1,8 @@ +# Final Schema SQL + +This folder contains the final PostgreSQL table definitions, one table per file. + +Notes: +- These files keep only the final schema shape. +- They do not preserve historical migration steps. +- The current runtime migration logic in `server-node/src/db/migrations.ts` is unchanged. diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index 476d0f4d..a1ef8272 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -1821,9 +1821,164 @@ test('runtime persistence is isolated by user', async () => { }, ); const userBLibraryPayload = (await userBLibrary.json()) as { - profiles: unknown[]; + entries: unknown[]; }; - assert.deepEqual(userBLibraryPayload.profiles, []); + assert.deepEqual(userBLibraryPayload.entries, []); + }); +}); + +test('custom worlds stay private until published and then appear in the public gallery', async () => { + await withTestServer('custom-world-gallery', async ({ baseUrl }) => { + const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123'); + const viewer = await authEntry(baseUrl, 'gallery_viewer', 'secret123'); + + const upsertResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a`, + withBearer(owner.token, { + method: 'PUT', + body: JSON.stringify({ + profile: { + id: 'world-a', + name: '裂桥前线', + subtitle: '边境上空的断层回响', + summary: '围绕裂桥哨线与失序潮汐展开的前线世界。', + tone: '压迫、冷峻、持续失衡', + playerGoal: '在裂桥崩塌前守住归路', + majorFactions: ['裂桥守军'], + coreConflicts: ['断层外压正在逼近城线'], + playableNpcs: [ + { + id: 'role-1', + name: '沈昼', + }, + ], + storyNpcs: [], + landmarks: [ + { + id: 'landmark-1', + name: '裂桥前哨', + description: '裂谷边缘的前线哨卡。', + dangerLevel: '高', + sceneNpcIds: [], + connections: [], + }, + ], + }, + }), + }), + ); + const upsertPayload = (await upsertResponse.json()) as { + entry: { + visibility: 'draft' | 'published'; + authorDisplayName: string; + }; + entries: unknown[]; + }; + + assert.equal(upsertResponse.status, 200); + assert.equal(upsertPayload.entry.visibility, 'draft'); + assert.equal(upsertPayload.entry.authorDisplayName, 'gallery_owner'); + + const galleryBeforePublish = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery`, + { + headers: { + Authorization: `Bearer ${viewer.token}`, + }, + }, + ); + const galleryBeforePayload = (await galleryBeforePublish.json()) as { + entries: unknown[]; + }; + assert.deepEqual(galleryBeforePayload.entries, []); + + const publishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a/publish`, + withBearer(owner.token, { + method: 'POST', + }), + ); + const publishPayload = (await publishResponse.json()) as { + entry: { + visibility: 'draft' | 'published'; + publishedAt: string | null; + }; + }; + + assert.equal(publishResponse.status, 200); + assert.equal(publishPayload.entry.visibility, 'published'); + assert.ok(publishPayload.entry.publishedAt); + + const galleryAfterPublish = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery`, + { + headers: { + Authorization: `Bearer ${viewer.token}`, + }, + }, + ); + const galleryAfterPayload = (await galleryAfterPublish.json()) as { + entries: Array<{ + ownerUserId: string; + profileId: string; + worldName: string; + authorDisplayName: string; + }>; + }; + + assert.equal(galleryAfterPublish.status, 200); + assert.equal(galleryAfterPayload.entries.length, 1); + assert.equal(galleryAfterPayload.entries[0]?.worldName, '裂桥前线'); + assert.equal(galleryAfterPayload.entries[0]?.authorDisplayName, 'gallery_owner'); + + const galleryDetail = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`, + { + headers: { + Authorization: `Bearer ${viewer.token}`, + }, + }, + ); + const galleryDetailPayload = (await galleryDetail.json()) as { + entry: { + worldName: string; + profile: { + name: string; + }; + }; + }; + + assert.equal(galleryDetail.status, 200); + assert.equal(galleryDetailPayload.entry.worldName, '裂桥前线'); + assert.equal(galleryDetailPayload.entry.profile.name, '裂桥前线'); + + const unpublishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a/unpublish`, + withBearer(owner.token, { + method: 'POST', + }), + ); + const unpublishPayload = (await unpublishResponse.json()) as { + entry: { + visibility: 'draft' | 'published'; + }; + }; + + assert.equal(unpublishResponse.status, 200); + assert.equal(unpublishPayload.entry.visibility, 'draft'); + + const galleryAfterUnpublish = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery`, + { + headers: { + Authorization: `Bearer ${viewer.token}`, + }, + }, + ); + const galleryAfterUnpublishPayload = (await galleryAfterUnpublish.json()) as { + entries: unknown[]; + }; + assert.deepEqual(galleryAfterUnpublishPayload.entries, []); }); }); diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts index 5ef6a2c5..a7fbc32b 100644 --- a/server-node/src/db.test.ts +++ b/server-node/src/db.test.ts @@ -109,6 +109,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () = '20260409_007_sms_auth_events', '20260409_008_auth_risk_blocks', '20260413_009_custom_world_sessions', + '20260414_010_custom_world_gallery_metadata', ], ); diff --git a/server-node/src/db/migrations.ts b/server-node/src/db/migrations.ts index 8f899352..3031db43 100644 --- a/server-node/src/db/migrations.ts +++ b/server-node/src/db/migrations.ts @@ -206,4 +206,32 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ ON custom_world_sessions (user_id, updated_at DESC)`, ], }, + { + id: '20260414_010_custom_world_gallery_metadata', + name: 'custom world gallery metadata', + statements: [ + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'draft'`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS published_at TEXT`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS author_display_name TEXT NOT NULL DEFAULT '玩家'`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS world_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS subtitle TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS summary_text TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS cover_image_src TEXT`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS theme_mode TEXT NOT NULL DEFAULT 'mythic'`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS playable_npc_count INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE custom_world_profiles + ADD COLUMN IF NOT EXISTS landmark_count INTEGER NOT NULL DEFAULT 0`, + `CREATE INDEX IF NOT EXISTS custom_world_profiles_published_idx + ON custom_world_profiles (visibility, published_at DESC, updated_at DESC)`, + ], + }, ]; diff --git a/server-node/src/repositories/customWorldLibraryMetadata.ts b/server-node/src/repositories/customWorldLibraryMetadata.ts new file mode 100644 index 00000000..0cfb4b28 --- /dev/null +++ b/server-node/src/repositories/customWorldLibraryMetadata.ts @@ -0,0 +1,109 @@ +import type { + CustomWorldProfileRecord, + CustomWorldThemeMode, +} from '../../../packages/shared/src/contracts/runtime.js'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown, fallback = '') { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function readArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function readImageSrc(value: unknown) { + return readString(value) || null; +} + +function detectThemeMode( + profile: Pick< + CustomWorldProfileRecord, + | 'settingText' + | 'summary' + | 'tone' + | 'playerGoal' + | 'templateWorldType' + | 'compatibilityTemplateWorldType' + | 'ownedSettingLayers' + >, +): CustomWorldThemeMode { + const semanticAnchor = isRecord(profile.ownedSettingLayers) + && isRecord(profile.ownedSettingLayers.semanticAnchor) + ? profile.ownedSettingLayers.semanticAnchor + : null; + const expressionProfile = isRecord(profile.ownedSettingLayers) + && isRecord(profile.ownedSettingLayers.expressionProfile) + ? profile.ownedSettingLayers.expressionProfile + : null; + const source = [ + readString(profile.settingText), + readString(profile.summary), + readString(profile.tone), + readString(profile.playerGoal), + ...readArray(semanticAnchor?.genreSignals).map((value) => readString(value)), + ...readArray(semanticAnchor?.conflictForms).map((value) => readString(value)), + ...readArray(semanticAnchor?.institutionTypes).map((value) => readString(value)), + ...readArray(semanticAnchor?.tabooTypes).map((value) => readString(value)), + ...readArray(semanticAnchor?.carrierTypes).map((value) => readString(value)), + ...readArray(semanticAnchor?.forceSystemTypes).map((value) => readString(value)), + ...readArray(semanticAnchor?.atmosphereTags).map((value) => readString(value)), + ...readArray(expressionProfile?.presentationTone).map((value) => readString(value)), + ].join(' '); + + if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina'; + if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide'; + if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift'; + if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane'; + if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial'; + + return 'mythic'; +} + +export function buildCustomWorldCoverImageSrc(profile: CustomWorldProfileRecord) { + const explicitCampImage = isRecord(profile.camp) + ? readImageSrc(profile.camp.imageSrc) + : null; + if (explicitCampImage) { + return explicitCampImage; + } + + const landmarkImage = readArray(profile.landmarks) + .map((landmark) => (isRecord(landmark) ? readImageSrc(landmark.imageSrc) : null)) + .find(Boolean); + if (landmarkImage) { + return landmarkImage; + } + + const playableImage = readArray(profile.playableNpcs) + .map((role) => (isRecord(role) ? readImageSrc(role.imageSrc) : null)) + .find(Boolean); + if (playableImage) { + return playableImage; + } + + return null; +} + +export function extractCustomWorldLibraryMetadata(profile: CustomWorldProfileRecord) { + return { + worldName: readString(profile.name, '未命名世界'), + subtitle: readString(profile.subtitle), + summaryText: readString(profile.summary), + coverImageSrc: buildCustomWorldCoverImageSrc(profile), + themeMode: detectThemeMode({ + settingText: profile.settingText, + summary: profile.summary, + tone: profile.tone, + playerGoal: profile.playerGoal, + templateWorldType: profile.templateWorldType, + compatibilityTemplateWorldType: profile.compatibilityTemplateWorldType, + ownedSettingLayers: profile.ownedSettingLayers, + }), + playableNpcCount: readArray(profile.playableNpcs).length, + landmarkCount: readArray(profile.landmarks).length, + }; +} diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index e1136b81..ea171299 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -7,12 +7,17 @@ import type { } from '../../../packages/shared/src/contracts/runtime.js'; import { type CustomWorldSessionRecord, + type CustomWorldGalleryCard, + type CustomWorldLibraryEntry, + type CustomWorldPublicationStatus, DEFAULT_MUSIC_VOLUME, SAVE_SNAPSHOT_VERSION, } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppDatabase } from '../db.js'; +import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; const MAX_CUSTOM_WORLD_PROFILES = 12; +const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36; export type SavedSnapshot = SavedGameSnapshot; @@ -28,9 +33,21 @@ type SettingsRow = QueryResultRow & { musicVolume: number; }; -type ProfileRow = QueryResultRow & { +type CustomWorldEntryRow = QueryResultRow & { + ownerUserId: string; + profileId: string; payload: CustomWorldProfileRecord; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldLibraryEntry['themeMode']; + playableNpcCount: number; + landmarkCount: number; }; type SessionRow = QueryResultRow & { @@ -39,6 +56,22 @@ type SessionRow = QueryResultRow & { updatedAt: string; }; +type CustomWorldCardRow = QueryResultRow & { + ownerUserId: string; + profileId: string; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; + updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldGalleryCard['themeMode']; + playableNpcCount: number; + landmarkCount: number; +}; + export type RuntimeRepositoryPort = { getSnapshot(userId: string): Promise; putSnapshot( @@ -51,17 +84,25 @@ export type RuntimeRepositoryPort = { userId: string, settings: RuntimeSettings, ): Promise; - listCustomWorldProfiles(userId: string): Promise; + listCustomWorldProfiles( + userId: string, + ): Promise[]>; upsertCustomWorldProfile( userId: string, profileId: string, profile: Record, - ): Promise; + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + }>; deleteCustomWorldProfile( userId: string, profileId: string, - ): Promise; - listCustomWorldSessions(userId: string): Promise; + ): Promise[]>; + listCustomWorldSessions( + userId: string, + ): Promise; getCustomWorldSession( userId: string, sessionId: string, @@ -71,11 +112,118 @@ export type RuntimeRepositoryPort = { sessionId: string, session: CustomWorldSessionRecord, ): Promise; + publishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + } | null>; + unpublishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + } | null>; + listPublishedCustomWorldGallery(): Promise; + getPublishedCustomWorldGalleryDetail( + ownerUserId: string, + profileId: string, + ): Promise | null>; }; +function normalizeStoredProfile( + profileId: string, + profile: Record, +): CustomWorldProfileRecord { + return { + ...profile, + id: profileId, + }; +} + +function toCustomWorldLibraryEntry( + row: CustomWorldEntryRow, +): CustomWorldLibraryEntry { + const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload); + + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + profile: row.payload, + visibility: row.visibility, + publishedAt: row.publishedAt, + updatedAt: row.updatedAt, + authorDisplayName: row.authorDisplayName || '玩家', + worldName: row.worldName || fallbackMetadata.worldName, + subtitle: row.subtitle || fallbackMetadata.subtitle, + summaryText: row.summaryText || fallbackMetadata.summaryText, + coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc, + themeMode: row.themeMode || fallbackMetadata.themeMode, + playableNpcCount: + row.playableNpcCount > 0 + ? row.playableNpcCount + : fallbackMetadata.playableNpcCount, + landmarkCount: + row.landmarkCount > 0 ? row.landmarkCount : fallbackMetadata.landmarkCount, + }; +} + +function toCustomWorldGalleryCard( + row: CustomWorldCardRow, +): CustomWorldGalleryCard { + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + visibility: row.visibility, + publishedAt: row.publishedAt, + updatedAt: row.updatedAt, + authorDisplayName: row.authorDisplayName || '玩家', + worldName: row.worldName || '未命名世界', + subtitle: row.subtitle || '', + summaryText: row.summaryText || '', + coverImageSrc: row.coverImageSrc || null, + themeMode: row.themeMode || 'mythic', + playableNpcCount: row.playableNpcCount, + landmarkCount: row.landmarkCount, + }; +} + export class RuntimeRepository implements RuntimeRepositoryPort { constructor(private readonly db: AppDatabase) {} + private async findCustomWorldProfileEntry( + userId: string, + profileId: string, + ) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND profile_id = $2`, + [userId, profileId], + ); + + const row = result.rows[0]; + return row ? toCustomWorldLibraryEntry(row) : null; + } + async getSnapshot(userId: string) { const result = await this.db.query( `SELECT version, @@ -192,42 +340,92 @@ export class RuntimeRepository implements RuntimeRepositoryPort { } async listCustomWorldProfiles(userId: string) { - const result = await this.db.query( - `SELECT payload_json AS payload, - updated_at AS "updatedAt" + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" FROM custom_world_profiles WHERE user_id = $1 ORDER BY updated_at DESC LIMIT $2`, [userId, MAX_CUSTOM_WORLD_PROFILES], ); - - return result.rows.map((row: ProfileRow) => ({ - ...row.payload, - updatedAt: row.updatedAt, - })); + return result.rows.map((row) => toCustomWorldLibraryEntry(row)); } async upsertCustomWorldProfile( userId: string, profileId: string, - profile: CustomWorldProfileRecord, + profile: Record, + authorDisplayName: string, ) { - const payload = { - ...profile, - id: profileId, - }; + const payload = normalizeStoredProfile(profileId, profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); await this.db.query( - `INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at) - VALUES ($1, $2, $3, $4) + `INSERT INTO custom_world_profiles ( + user_id, + profile_id, + payload_json, + updated_at, + author_display_name, + world_name, + subtitle, + summary_text, + cover_image_src, + theme_mode, + playable_npc_count, + landmark_count + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (user_id, profile_id) DO UPDATE SET payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at`, - [userId, profileId, payload, new Date().toISOString()], + updated_at = EXCLUDED.updated_at, + author_display_name = EXCLUDED.author_display_name, + world_name = EXCLUDED.world_name, + subtitle = EXCLUDED.subtitle, + summary_text = EXCLUDED.summary_text, + cover_image_src = EXCLUDED.cover_image_src, + theme_mode = EXCLUDED.theme_mode, + playable_npc_count = EXCLUDED.playable_npc_count, + landmark_count = EXCLUDED.landmark_count`, + [ + userId, + profileId, + payload, + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + ], ); - return this.listCustomWorldProfiles(userId); + const entry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after upsert'); + } + + return { + entry, + entries: await this.listCustomWorldProfiles(userId), + }; } async deleteCustomWorldProfile(userId: string, profileId: string) { @@ -310,4 +508,169 @@ export class RuntimeRepository implements RuntimeRepositoryPort { updatedAt: session.updatedAt, }; } + + async publishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!existingEntry) { + return null; + } + + const payload = normalizeStoredProfile(profileId, existingEntry.profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `UPDATE custom_world_profiles + SET visibility = 'published', + published_at = $1, + updated_at = $1, + author_display_name = $2, + world_name = $3, + subtitle = $4, + summary_text = $5, + cover_image_src = $6, + theme_mode = $7, + playable_npc_count = $8, + landmark_count = $9 + WHERE user_id = $10 + AND profile_id = $11`, + [ + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + userId, + profileId, + ], + ); + + const entry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after publish'); + } + + return { + entry, + entries: await this.listCustomWorldProfiles(userId), + }; + } + + async unpublishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!existingEntry) { + return null; + } + + const payload = normalizeStoredProfile(profileId, existingEntry.profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `UPDATE custom_world_profiles + SET visibility = 'draft', + published_at = NULL, + updated_at = $1, + author_display_name = $2, + world_name = $3, + subtitle = $4, + summary_text = $5, + cover_image_src = $6, + theme_mode = $7, + playable_npc_count = $8, + landmark_count = $9 + WHERE user_id = $10 + AND profile_id = $11`, + [ + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + userId, + profileId, + ], + ); + + const entry = await this.findCustomWorldProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after unpublish'); + } + + return { + entry, + entries: await this.listCustomWorldProfiles(userId), + }; + } + + async listPublishedCustomWorldGallery() { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE visibility = 'published' + ORDER BY published_at DESC, updated_at DESC + LIMIT $1`, + [MAX_PUBLIC_CUSTOM_WORLD_PROFILES], + ); + + return result.rows.map((row) => toCustomWorldGalleryCard(row)); + } + + async getPublishedCustomWorldGalleryDetail( + ownerUserId: string, + profileId: string, + ) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND profile_id = $2 + AND visibility = 'published'`, + [ownerUserId, profileId], + ); + + const row = result.rows[0]; + return row ? toCustomWorldLibraryEntry(row) : null; + } } diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index bb634d69..5c926d09 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -5,6 +5,10 @@ import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/ import type { AnswerCustomWorldSessionQuestionRequest, CreateCustomWorldSessionRequest, + CustomWorldGalleryDetailResponse, + CustomWorldGalleryResponse, + CustomWorldLibraryMutationResponse, + CustomWorldLibraryResponse, RuntimeSettings, SavedGameSnapshotInput, } from '../../../packages/shared/src/contracts/runtime.js'; @@ -109,6 +113,15 @@ function readParam(param: string | string[] | undefined) { return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; } +async function resolveAuthDisplayName(context: AppContext, userId: string) { + const user = await context.userRepository.findById(userId); + if (!user) { + throw notFound('user not found'); + } + + return user.displayName?.trim() || '玩家'; +} + export function createRuntimeRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); @@ -224,11 +237,55 @@ export function createRuntimeRoutes(context: AppContext) { '/runtime/custom-world-library', routeMeta({ operation: 'runtime.customWorldLibrary.list' }), asyncHandler(async (request, response) => { - sendApiResponse(response, { - profiles: await context.runtimeRepository.listCustomWorldProfiles( - request.userId!, - ), - }); + sendApiResponse( + response, + { + entries: await context.runtimeRepository.listCustomWorldProfiles( + request.userId!, + ), + } satisfies CustomWorldLibraryResponse, + ); + }), + ); + + router.get( + '/runtime/custom-world-gallery', + routeMeta({ operation: 'runtime.customWorldGallery.list' }), + asyncHandler(async (_request, response) => { + sendApiResponse( + response, + { + entries: await context.runtimeRepository.listPublishedCustomWorldGallery(), + } satisfies CustomWorldGalleryResponse, + ); + }), + ); + + router.get( + '/runtime/custom-world-gallery/:ownerUserId/:profileId', + routeMeta({ operation: 'runtime.customWorldGallery.detail' }), + asyncHandler(async (request, response) => { + const ownerUserId = readParam(request.params.ownerUserId); + const profileId = readParam(request.params.profileId); + if (!ownerUserId || !profileId) { + throw badRequest('ownerUserId and profileId are required'); + } + + const entry = + await context.runtimeRepository.getPublishedCustomWorldGalleryDetail( + ownerUserId, + profileId, + ); + if (!entry) { + throw notFound('public custom world not found'); + } + + sendApiResponse( + response, + { + entry, + } satisfies CustomWorldGalleryDetailResponse, + ); }), ); @@ -241,13 +298,19 @@ export function createRuntimeRoutes(context: AppContext) { throw badRequest('profileId is required'); } const payload = customWorldProfileSchema.parse(request.body); - sendApiResponse(response, { - profiles: await context.runtimeRepository.upsertCustomWorldProfile( + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + sendApiResponse( + response, + await context.runtimeRepository.upsertCustomWorldProfile( request.userId!, profileId, jsonClone(payload.profile), + authorDisplayName, ), - }); + ); }), ); @@ -259,12 +322,75 @@ export function createRuntimeRoutes(context: AppContext) { if (!profileId) { throw badRequest('profileId is required'); } - sendApiResponse(response, { - profiles: await context.runtimeRepository.deleteCustomWorldProfile( + sendApiResponse( + response, + { + entries: await context.runtimeRepository.deleteCustomWorldProfile( + request.userId!, + profileId, + ), + } satisfies CustomWorldLibraryResponse, + ); + }), + ); + + router.post( + '/runtime/custom-world-library/:profileId/publish', + routeMeta({ operation: 'runtime.customWorldLibrary.publish' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + const mutation = + await context.runtimeRepository.publishCustomWorldProfile( request.userId!, profileId, - ), - }); + authorDisplayName, + ); + if (!mutation) { + throw notFound('custom world not found'); + } + + sendApiResponse( + response, + mutation satisfies CustomWorldLibraryMutationResponse, + ); + }), + ); + + router.post( + '/runtime/custom-world-library/:profileId/unpublish', + routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + const mutation = + await context.runtimeRepository.unpublishCustomWorldProfile( + request.userId!, + profileId, + authorDisplayName, + ); + if (!mutation) { + throw notFound('custom world not found'); + } + + sendApiResponse( + response, + mutation satisfies CustomWorldLibraryMutationResponse, + ); }), ); diff --git a/server-node/src/services/customWorldAgentDraftCompiler.ts b/server-node/src/services/customWorldAgentDraftCompiler.ts index 733994b7..2d459f0a 100644 --- a/server-node/src/services/customWorldAgentDraftCompiler.ts +++ b/server-node/src/services/customWorldAgentDraftCompiler.ts @@ -929,14 +929,7 @@ export class CustomWorldAgentDraftCompiler { ] .filter(Boolean) .join(' / '), - summary: - assetHeadline.status === 'complete' - ? clampText(`${character.summary}(核心动作已就绪)`, 180) - : assetHeadline.status === 'visual_ready' - ? clampText(`${character.summary}(主图已就绪)`, 180) - : assetHeadline.status === 'animations_ready' - ? clampText(`${character.summary}(动作补齐中)`, 180) - : clampText(`${character.summary}(待生成主图)`, 180), + summary: clampText(character.summary, 180), linkedIds: [...character.threadIds, ...linkedLandmarks].slice(0, 6), sections: [ buildSection('name', '角色名', character.name), diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts index d5274831..96fa5465 100644 --- a/server-node/src/services/customWorldWorkSummaryService.ts +++ b/server-node/src/services/customWorldWorkSummaryService.ts @@ -2,7 +2,10 @@ import type { CustomWorldAgentStage, CustomWorldWorkSummary, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import type { + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { @@ -139,6 +142,18 @@ function resolvePublishedCover(profile: Record) { return toText(camp?.imageSrc) || toText(leadNpc?.imageSrc) || null; } +function isLibraryEntry( + value: unknown, +): value is CustomWorldLibraryEntry { + const record = toRecord(value); + return ( + Boolean(record) && + typeof record.ownerUserId === 'string' && + typeof record.profileId === 'string' && + Boolean(toRecord(record.profile)) + ); +} + export async function listCustomWorldWorkSummaries( userId: string, dependencies: { @@ -182,12 +197,16 @@ export async function listCustomWorldWorkSummaries( }); const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => { - const profileRecord = profile as CustomWorldProfileRecord & - Record; + const libraryEntry = isLibraryEntry(profile) ? profile : null; + const profileRecord = ( + libraryEntry?.profile ?? profile + ) as CustomWorldProfileRecord & Record; const playableNpcs = toRecordArray(profileRecord.playableNpcs); const landmarks = toRecordArray(profileRecord.landmarks); const updatedAt = - toText(profileRecord.updatedAt) || new Date().toISOString(); + (libraryEntry ? toText(libraryEntry.updatedAt) : '') || + toText(profileRecord.updatedAt) || + new Date().toISOString(); const roleVisualReadyCount = playableNpcs.filter( (entry) => Boolean(toText(entry.imageSrc)) && @@ -201,17 +220,36 @@ export async function listCustomWorldWorkSummaries( workId: `published:${toText(profileRecord.id) || updatedAt}`, sourceType: 'published_profile', status: 'published', - title: toText(profileRecord.name) || '未命名世界', - subtitle: toText(profileRecord.subtitle) || '已发布作品', + title: + (libraryEntry ? toText(libraryEntry.worldName) : '') || + toText(profileRecord.name) || + '未命名世界', + subtitle: + (libraryEntry ? toText(libraryEntry.subtitle) : '') || + toText(profileRecord.subtitle) || + '已保存作品', summary: - toText(profileRecord.summary) || '这个世界已经可以直接进入体验。', - coverImageSrc: resolvePublishedCover(profileRecord), + (libraryEntry ? toText(libraryEntry.summaryText) : '') || + toText(profileRecord.summary) || + '这个世界已经可以直接进入体验。', + coverImageSrc: + (libraryEntry ? libraryEntry.coverImageSrc : null) || + resolvePublishedCover(profileRecord), updatedAt, - publishedAt: toText(profileRecord.publishedAt) || updatedAt, + publishedAt: + (libraryEntry ? toText(libraryEntry.publishedAt) : '') || + toText(profileRecord.publishedAt) || + updatedAt, stage: 'published', stageLabel: '已发布', - playableNpcCount: playableNpcs.length, - landmarkCount: landmarks.length, + playableNpcCount: + (libraryEntry?.playableNpcCount ?? 0) > 0 + ? libraryEntry!.playableNpcCount + : playableNpcs.length, + landmarkCount: + (libraryEntry?.landmarkCount ?? 0) > 0 + ? libraryEntry!.landmarkCount + : landmarks.length, roleVisualReadyCount, roleAnimationReadyCount, roleAssetSummaryLabel: @@ -221,7 +259,10 @@ export async function listCustomWorldWorkSummaries( ? `主图已就绪 ${roleVisualReadyCount}` : null, sessionId: null, - profileId: toText(profileRecord.id) || null, + profileId: + (libraryEntry ? toText(libraryEntry.profileId) : '') || + toText(profileRecord.id) || + null, canResume: false, canEnterWorld: true, }; diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 620d9765..44a3d0a5 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -20,12 +20,8 @@ import { import { type CustomWorldSceneImageResult, generateCustomWorldSceneImage, -} from '../services/ai'; +} from '../services/aiService'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; -import { - buildCustomWorldSceneImagePrompt, - DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, -} from '../services/customWorld'; import { AnimationState, CustomWorldLandmark, diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx index 6e8e39e0..8ba9c329 100644 --- a/src/components/CustomWorldResultView.tsx +++ b/src/components/CustomWorldResultView.tsx @@ -217,7 +217,7 @@ export function CustomWorldResultView({ style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })} >
- 保存并进入世界 + 保存到我的作品
diff --git a/src/components/GameShell.tsx b/src/components/GameShell.tsx index 5bc30ffe..81080c5b 100644 --- a/src/components/GameShell.tsx +++ b/src/components/GameShell.tsx @@ -2,6 +2,7 @@ import {AnimatePresence, motion} from 'motion/react'; import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react'; import {getLiveGamePlayTimeMs} from '../data/runtimeStats'; +import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes'; import {getWorldCampScenePreset} from '../data/scenePresets'; import {BottomTab} from '../hooks/useGameFlow'; import { @@ -55,6 +56,7 @@ interface GameShellStoryProps { interface GameShellEntryProps { hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: () => void; handleStartNewGame: () => void; handleSaveAndExit: () => void; @@ -208,6 +210,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP } = story; const { hasSavedGame, + savedSnapshot, handleContinueGame, handleStartNewGame, handleSaveAndExit, @@ -272,7 +275,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP !gameState.playerCharacter; const hideSelectionHero = gameState.currentScene === 'Selection' && - selectionStage !== 'start'; + selectionStage !== 'platform'; const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; const dialogueIndicator = useMemo(() => { @@ -428,6 +431,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP setSelectionStage={setSelectionStage} gameState={gameState} hasSavedGame={hasSavedGame} + savedSnapshot={savedSnapshot} handleContinueGame={handleContinueGame} handleStartNewGame={handleStartNewGame} handleCustomWorldSelect={handleCustomWorldSelect} @@ -447,7 +451,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP customWorldProfile={gameState.customWorldProfile} onBack={() => { handleBackToWorldSelect(); - setSelectionStage('world'); + setSelectionStage('platform'); }} onConfirm={handleCharacterSelect} /> diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 3c590d9f..e51f4111 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { AUTH_STATE_EVENT, @@ -30,6 +30,7 @@ import { startWechatLogin, } from '../../services/authService'; import { AccountModal } from './AccountModal'; +import { AuthUiContext } from './AuthUiContext'; import { BindPhoneScreen } from './BindPhoneScreen'; import { LoginScreen } from './LoginScreen'; @@ -61,6 +62,7 @@ export function AuthGate({ children }: AuthGateProps) { const [bindingPhone, setBindingPhone] = useState(false); const [wechatLoading, setWechatLoading] = useState(false); const [showAccountModal, setShowAccountModal] = useState(false); + const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true); const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); const [auditLogs, setAuditLogs] = useState([]); @@ -304,6 +306,19 @@ export function AuthGate({ children }: AuthGateProps) { }; }, [showAccountModal, status]); + const authUiValue = useMemo( + () => ({ + user, + openAccountModal: () => setShowAccountModal(true), + logout: async () => { + await logoutAuthUser(); + setShowAccountModal(false); + }, + setGlobalAccountActionsVisible: setShowGlobalAccountActions, + }), + [user], + ); + if (status === 'checking') { return (
@@ -468,140 +483,144 @@ export function AuthGate({ children }: AuthGateProps) { } return ( -
-
-
- - -
-
- setShowAccountModal(false)} - onLogout={async () => { - await logoutAuthUser(); - setShowAccountModal(false); - }} - onRefreshRiskBlocks={async () => { - setLoadingRiskBlocks(true); - try { - setRiskBlocks(await getAuthRiskBlocks()); - } catch (blockError) { - setError( - blockError instanceof Error - ? blockError.message - : '读取安全状态失败,请稍后再试。', - ); - } finally { - setLoadingRiskBlocks(false); - } - }} - onLiftRiskBlock={async (scopeType) => { - try { - await liftAuthRiskBlock(scopeType); - setRiskBlocks(await getAuthRiskBlocks()); - setAuditLogs(await getAuthAuditLogs()); - } catch (liftError) { - setError( - liftError instanceof Error - ? liftError.message - : '解除保护失败,请稍后再试。', - ); - } - }} - onRefreshSessions={async () => { - setLoadingSessions(true); - try { - setSessions(await getAuthSessions()); - } catch (sessionError) { - setError( - sessionError instanceof Error - ? sessionError.message - : '读取登录设备失败,请稍后再试。', - ); - } finally { - setLoadingSessions(false); - } - }} - onRefreshAuditLogs={async () => { - setLoadingAuditLogs(true); - try { - setAuditLogs(await getAuthAuditLogs()); - } catch (auditError) { - setError( - auditError instanceof Error - ? auditError.message - : '读取账号操作记录失败,请稍后再试。', - ); - } finally { - setLoadingAuditLogs(false); - } - }} - onRevokeSession={async (sessionId) => { - try { - await revokeAuthSession(sessionId); - setSessions((current) => - current.filter((session) => session.sessionId !== sessionId), - ); - setAuditLogs(await getAuthAuditLogs()); - } catch (revokeError) { - setError( - revokeError instanceof Error - ? revokeError.message - : '移除登录设备失败,请稍后再试。', - ); - } - }} - onLogoutAll={async () => { - await logoutAllAuthSessions(); - setShowAccountModal(false); - }} - changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} - onSendChangePhoneCode={async (phone, captcha) => { - try { - const result = await sendPhoneLoginCode( - phone, - 'change_phone', - captcha, - ); - setChangePhoneCaptchaChallenge(null); - return result; - } catch (sendError) { - const captchaChallenge = getCaptchaChallengeFromError(sendError); - if (captchaChallenge) { - setChangePhoneCaptchaChallenge(captchaChallenge); + +
+ {showGlobalAccountActions ? ( +
+
+ + +
+
+ ) : null} + setShowAccountModal(false)} + onLogout={async () => { + await logoutAuthUser(); + setShowAccountModal(false); + }} + onRefreshRiskBlocks={async () => { + setLoadingRiskBlocks(true); + try { + setRiskBlocks(await getAuthRiskBlocks()); + } catch (blockError) { + setError( + blockError instanceof Error + ? blockError.message + : '读取安全状态失败,请稍后再试。', + ); + } finally { + setLoadingRiskBlocks(false); } - throw sendError; - } - }} - onChangePhone={async (phone, code) => { - const nextUser = await changePhoneNumber(phone, code); - setChangePhoneCaptchaChallenge(null); - setUser(nextUser); - }} - /> - {children} -
+ }} + onLiftRiskBlock={async (scopeType) => { + try { + await liftAuthRiskBlock(scopeType); + setRiskBlocks(await getAuthRiskBlocks()); + setAuditLogs(await getAuthAuditLogs()); + } catch (liftError) { + setError( + liftError instanceof Error + ? liftError.message + : '解除保护失败,请稍后再试。', + ); + } + }} + onRefreshSessions={async () => { + setLoadingSessions(true); + try { + setSessions(await getAuthSessions()); + } catch (sessionError) { + setError( + sessionError instanceof Error + ? sessionError.message + : '读取登录设备失败,请稍后再试。', + ); + } finally { + setLoadingSessions(false); + } + }} + onRefreshAuditLogs={async () => { + setLoadingAuditLogs(true); + try { + setAuditLogs(await getAuthAuditLogs()); + } catch (auditError) { + setError( + auditError instanceof Error + ? auditError.message + : '读取账号操作记录失败,请稍后再试。', + ); + } finally { + setLoadingAuditLogs(false); + } + }} + onRevokeSession={async (sessionId) => { + try { + await revokeAuthSession(sessionId); + setSessions((current) => + current.filter((session) => session.sessionId !== sessionId), + ); + setAuditLogs(await getAuthAuditLogs()); + } catch (revokeError) { + setError( + revokeError instanceof Error + ? revokeError.message + : '移除登录设备失败,请稍后再试。', + ); + } + }} + onLogoutAll={async () => { + await logoutAllAuthSessions(); + setShowAccountModal(false); + }} + changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} + onSendChangePhoneCode={async (phone, captcha) => { + try { + const result = await sendPhoneLoginCode( + phone, + 'change_phone', + captcha, + ); + setChangePhoneCaptchaChallenge(null); + return result; + } catch (sendError) { + const captchaChallenge = getCaptchaChallengeFromError(sendError); + if (captchaChallenge) { + setChangePhoneCaptchaChallenge(captchaChallenge); + } + throw sendError; + } + }} + onChangePhone={async (phone, code) => { + const nextUser = await changePhoneNumber(phone, code); + setChangePhoneCaptchaChallenge(null); + setUser(nextUser); + }} + /> + {children} +
+ ); } diff --git a/src/components/auth/AuthUiContext.ts b/src/components/auth/AuthUiContext.ts new file mode 100644 index 00000000..9b4a4220 --- /dev/null +++ b/src/components/auth/AuthUiContext.ts @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; + +import type { AuthUser } from '../../services/authService'; + +type AuthUiContextValue = { + user: AuthUser | null; + openAccountModal: () => void; + logout: () => Promise; + setGlobalAccountActionsVisible: (visible: boolean) => void; +}; + +export const AuthUiContext = createContext(null); + +export function useAuthUi() { + return useContext(AuthUiContext); +} diff --git a/src/components/game-shell/GameShellMainContent.tsx b/src/components/game-shell/GameShellMainContent.tsx index 628da06e..55cb43c7 100644 --- a/src/components/game-shell/GameShellMainContent.tsx +++ b/src/components/game-shell/GameShellMainContent.tsx @@ -1,4 +1,5 @@ import { AnimatePresence, motion } from 'motion/react'; +import { lazy, Suspense } from 'react'; import type { BottomTab } from '../../hooks/useGameFlow'; import type { @@ -8,6 +9,7 @@ import type { InventoryFlowUi, QuestFlowUi, } from '../../hooks/useStoryGeneration'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { CompanionRenderState, CustomWorldProfile, @@ -17,11 +19,38 @@ import type { } from '../../types'; import { UI_CHROME } from '../../uiAssets'; import type { GameCanvasEntitySelection } from '../GameCanvas'; -import { CharacterSelectionFlow } from './CharacterSelectionFlow'; -import { GameShellStoryPanels } from './GameShellStoryPanels'; -import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow'; +import type { SelectionStage } from './PreGameSelectionFlow'; import type { GameShellAdventureStatistics } from './types'; +const CharacterSelectionFlow = lazy(async () => { + const module = await import('./CharacterSelectionFlow'); + return { + default: module.CharacterSelectionFlow, + }; +}); +const PreGameSelectionFlow = lazy(async () => { + const module = await import('./PreGameSelectionFlow'); + return { + default: module.PreGameSelectionFlow, + }; +}); +const GameShellStoryPanels = lazy(async () => { + const module = await import('./GameShellStoryPanels'); + return { + default: module.GameShellStoryPanels, + }; +}); + +function MainContentLoadingFallback({ label }: { label: string }) { + return ( +
+
+ {label} +
+
+ ); +} + export function GameShellMainContent({ gameState, visibleGameState, @@ -34,6 +63,7 @@ export function GameShellMainContent({ setSelectionStage, isCharacterSelectionStage, hasSavedGame, + savedSnapshot, handleContinueGame, handleStartNewGame, handleCustomWorldSelect, @@ -71,6 +101,7 @@ export function GameShellMainContent({ setSelectionStage: (stage: SelectionStage) => void; isCharacterSelectionStage: boolean; hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: () => void; handleStartNewGame: () => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; @@ -110,15 +141,20 @@ export function GameShellMainContent({ > {!gameState.worldType && ( - + } + > + + )} {gameState.worldType && !gameState.playerCharacter && ( @@ -129,50 +165,58 @@ export function GameShellMainContent({ exit={{ opacity: 0, y: -12 }} className="flex h-full min-h-0 flex-col" > - { - handleBackToWorldSelect(); - setSelectionStage('world'); - }} - onConfirm={handleCharacterSelect} - /> + } + > + { + handleBackToWorldSelect(); + setSelectionStage('platform'); + }} + onConfirm={handleCharacterSelect} + /> + )} {visibleGameState.playerCharacter && visibleCurrentStory && ( - { - resetForSaveAndExit(); - handleSaveAndExit(); - }} - /> + } + > + { + resetForSaveAndExit(); + handleSaveAndExit(); + }} + /> + )} diff --git a/src/components/game-shell/GameShellRuntime.tsx b/src/components/game-shell/GameShellRuntime.tsx index 1584e595..399484fb 100644 --- a/src/components/game-shell/GameShellRuntime.tsx +++ b/src/components/game-shell/GameShellRuntime.tsx @@ -1,11 +1,26 @@ +import { lazy, Suspense, useEffect } from 'react'; + import { UI_CHROME } from '../../uiAssets'; -import { GameShellCanvasStage } from './GameShellCanvasStage'; +import { useAuthUi } from '../auth/AuthUiContext'; import { GameShellMainContent } from './GameShellMainContent'; -import { GameShellOverlays } from './GameShellOverlays'; import type { GameShellProps } from './types'; import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel'; +const GameShellOverlays = lazy(async () => { + const module = await import('./GameShellOverlays'); + return { + default: module.GameShellOverlays, + }; +}); +const GameShellCanvasStage = lazy(async () => { + const module = await import('./GameShellCanvasStage'); + return { + default: module.GameShellCanvasStage, + }; +}); + export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) { + const authUi = useAuthUi(); const { gameState, isLoading, @@ -29,6 +44,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam } = story; const { hasSavedGame, + savedSnapshot, handleContinueGame, handleStartNewGame, handleSaveAndExit, @@ -80,6 +96,14 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam companions, }); + useEffect(() => { + authUi?.setGlobalAccountActionsVisible(Boolean(gameState.playerCharacter)); + + return () => { + authUi?.setGlobalAccountActionsVisible(true); + }; + }, [authUi, gameState.playerCharacter]); + return (
- + + + - + + +
); } - diff --git a/src/components/game-shell/PlatformHomeView.tsx b/src/components/game-shell/PlatformHomeView.tsx new file mode 100644 index 00000000..bfa6ec87 --- /dev/null +++ b/src/components/game-shell/PlatformHomeView.tsx @@ -0,0 +1,356 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, +} from '../../../packages/shared/src/contracts/runtime'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import type { CustomWorldProfile } from '../../types'; +import { + CHROME_ICONS, + getNineSliceStyle, + UI_CHROME, +} from '../../uiAssets'; +import { useAuthUi } from '../auth/AuthUiContext'; +import { PixelIcon } from '../PixelIcon'; +import { + buildPlatformWorldTags, + describePlatformThemeLabel, + formatPlatformWorldTime, + type PlatformWorldCardLike, + resolvePlatformWorldCoverImage, + resolvePlatformWorldLeadPortrait, +} from './platformWorldPresentation'; + +function SectionHeader({ + title, + detail, + actionLabel, + onAction, +}: { + title: string; + detail: string; + actionLabel?: string; + onAction?: (() => void) | null; +}) { + return ( +
+
+
+ {detail} +
+
{title}
+
+ {actionLabel && onAction ? ( + + ) : null} +
+ ); +} + +function EmptyShelf({ + text, +}: { + text: string; +}) { + return ( +
+ {text} +
+ ); +} + +function WorldCard({ + entry, + badge, + metaLabel, + onClick, +}: { + entry: PlatformWorldCardLike; + badge: string; + metaLabel: string; + onClick: () => void; +}) { + const coverImage = resolvePlatformWorldCoverImage(entry); + const leadPortrait = resolvePlatformWorldLeadPortrait(entry); + const tags = buildPlatformWorldTags(entry); + + return ( + + ); +} + +export function PlatformHomeView({ + hasSavedGame, + savedSnapshot, + featuredEntries, + latestEntries, + myEntries, + isLoadingPlatform, + platformError, + onContinueGame, + onRefresh, + onOpenCreateWorld, + onOpenGalleryDetail, + onOpenLibraryDetail, +}: { + hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; + featuredEntries: CustomWorldGalleryCard[]; + latestEntries: CustomWorldGalleryCard[]; + myEntries: CustomWorldLibraryEntry[]; + isLoadingPlatform: boolean; + platformError: string | null; + onContinueGame: () => void; + onRefresh: () => void; + onOpenCreateWorld: () => void; + onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void; + onOpenLibraryDetail: (entry: CustomWorldLibraryEntry) => void; +}) { + const authUi = useAuthUi(); + const snapshotWorldName = + savedSnapshot?.gameState.customWorldProfile?.name ?? + savedSnapshot?.gameState.currentScenePreset?.name ?? + '继续冒险'; + const snapshotCharacterName = + savedSnapshot?.gameState.playerCharacter?.title ?? + savedSnapshot?.gameState.playerCharacter?.name ?? + '旅人'; + const featuredShelf = featuredEntries.slice(0, 6); + + return ( +
+
+
+
+ +
+
+
+ GENARRATIVE PLATFORM +
+
+ 自定义世界广场 +
+
+
+
+ + {authUi?.user ? ( + + ) : null} +
+
+ +
+
+ + + {platformError ? ( +
+ {platformError} +
+ ) : null} + +
+ + {isLoadingPlatform ? ( + + ) : featuredShelf.length > 0 ? ( +
+ {featuredShelf.map((entry) => ( + onOpenGalleryDetail(entry)} + /> + ))} +
+ ) : ( + + )} +
+ +
+ + {isLoadingPlatform ? ( + + ) : latestEntries.length > 0 ? ( +
+ {latestEntries.map((entry) => ( + onOpenGalleryDetail(entry)} + /> + ))} +
+ ) : ( + + )} +
+ +
+ +
+ + + {myEntries.map((entry) => ( + onOpenLibraryDetail(entry)} + /> + ))} +
+ {!isLoadingPlatform && myEntries.length === 0 ? ( +
+ +
+ ) : null} +
+
+
+
+ ); +} diff --git a/src/components/game-shell/PlatformWorldDetailView.tsx b/src/components/game-shell/PlatformWorldDetailView.tsx new file mode 100644 index 00000000..960e930f --- /dev/null +++ b/src/components/game-shell/PlatformWorldDetailView.tsx @@ -0,0 +1,278 @@ +import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; +import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import type { CustomWorldProfile } from '../../types'; +import { getNineSliceStyle, UI_CHROME } from '../../uiAssets'; +import { + buildPlatformWorldTags, + describePlatformThemeLabel, + formatPlatformWorldTime, + resolvePlatformWorldCoverImage, + resolvePlatformWorldLeadPortrait, +} from './platformWorldPresentation'; + +function ActionButton({ + label, + onClick, + tone = 'default', + disabled = false, +}: { + label: string; + onClick: () => void; + tone?: 'default' | 'primary' | 'danger'; + disabled?: boolean; +}) { + const toneClass = + tone === 'primary' + ? 'border-sky-300/25 bg-sky-500/10 text-sky-100 hover:border-sky-300/45 hover:text-white' + : tone === 'danger' + ? 'border-rose-400/25 bg-rose-500/10 text-rose-100 hover:border-rose-400/45 hover:text-white' + : 'border-white/10 bg-black/20 text-zinc-200 hover:border-white/20 hover:text-white'; + + return ( + + ); +} + +export function PlatformWorldDetailView({ + entry, + isMutating, + error, + onBack, + onStartGame, + onContinueEdit, + onPublish, + onUnpublish, +}: { + entry: CustomWorldLibraryEntry; + isMutating: boolean; + error: string | null; + onBack: () => void; + onStartGame: () => void; + onContinueEdit?: (() => void) | null; + onPublish?: (() => void) | null; + onUnpublish?: (() => void) | null; +}) { + const coverImage = resolvePlatformWorldCoverImage(entry); + const leadPortrait = resolvePlatformWorldLeadPortrait(entry); + const previewCharacters = buildCustomWorldPlayableCharacters(entry.profile).slice( + 0, + 3, + ); + const previewLandmarks = entry.profile.landmarks.slice(0, 3); + const tags = buildPlatformWorldTags(entry); + + return ( +
+
+ +
+ {entry.visibility === 'published' ? '已发布' : '草稿'} +
+
+ +
+
+
+ {coverImage ? ( + {entry.worldName} + ) : null} + {leadPortrait ? ( + + ) : null} +
+
+
+ + {describePlatformThemeLabel(entry.themeMode)} + + + {entry.authorDisplayName} + + + {entry.visibility === 'published' + ? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}` + : '仅自己可见'} + +
+
+ {entry.worldName} +
+ {entry.subtitle ? ( +
+ {entry.subtitle} +
+ ) : null} +
+ {entry.summaryText || '等待补充世界摘要。'} +
+
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+
+ +
+
+
+ 世界信息 +
+
+
+
+ 可玩角色 +
+
{entry.playableNpcCount}
+
+
+
+ 地标 +
+
{entry.landmarkCount}
+
+
+
+ 阵营 +
+
+ {entry.profile.majorFactions.length} +
+
+
+
+ 冲突 +
+
+ {entry.profile.coreConflicts.length} +
+
+
+ +
+
+ 关键角色 +
+
+ {previewCharacters.map((character) => ( +
+
+ {character.title} +
+
+ {character.description} +
+
+ ))} +
+
+ +
+
+ 关键场景 +
+
+ {previewLandmarks.map((landmark) => ( +
+
+ {landmark.name} +
+
+ {landmark.description} +
+
+ ))} +
+
+
+ +
+
+ 操作 +
+
+ + {onContinueEdit ? ( + + ) : null} + {onPublish ? ( + + ) : null} + {onUnpublish ? ( + + ) : null} +
+ {error ? ( +
+ {error} +
+ ) : null} +
+
+
+
+
+ ); +} diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index 04046b21..f7141b13 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -1,749 +1,850 @@ import { AnimatePresence, motion } from 'motion/react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { JsonObject } from '../../../packages/shared/src/contracts/common'; import type { - CustomWorldAgentActionRequest, - CustomWorldAgentOperationRecord, - CustomWorldAgentSessionSnapshot, - CustomWorldWorkSummary, - SendCustomWorldAgentMessageRequest, -} from '../../../packages/shared/src/contracts/customWorldAgent'; + CustomWorldGalleryCard, + CustomWorldGenerationProgress, + CustomWorldLibraryEntry, +} from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; -import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import { generateCustomWorldProfile } from '../../services/aiService'; import { - createCustomWorldAgentSession, - executeCustomWorldAgentAction, - getCustomWorldAgentOperation, - getCustomWorldAgentSession, - listCustomWorldWorks, - sendCustomWorldAgentMessage, -} from '../../services/aiService'; + buildCustomWorldCreatorIntentDisplayText, + buildCustomWorldCreatorIntentGenerationText, + createEmptyCustomWorldCreatorIntent, +} from '../../services/customWorldCreatorIntent'; import { - clearCustomWorldAgentUiState, - readCustomWorldAgentUiState, - writeCustomWorldAgentUiState, -} from '../../services/customWorldAgentUiState'; -import { detectCustomWorldThemeMode } from '../../services/customWorldTheme'; -import { listCustomWorldLibrary } from '../../services/storageService'; -import { type CustomWorldProfile, type GameState } from '../../types'; + getCustomWorldGalleryDetail, + listCustomWorldGallery, + listCustomWorldLibrary, + publishCustomWorldProfile, + unpublishCustomWorldProfile, + upsertCustomWorldProfile, +} from '../../services/storageService'; import { - CHROME_ICONS, - CUSTOM_WORLD_THEME_ICONS, - getNineSliceStyle, - UI_CHROME, -} from '../../uiAssets'; -import { CustomWorldAgentWorkspace } from '../custom-world-agent/CustomWorldAgentWorkspace'; -import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub'; -import { DeveloperTeamModal } from '../DeveloperTeamModal'; -import { PixelIcon } from '../PixelIcon'; + type CustomWorldCreatorIntent, + type CustomWorldGenerationMode, + type CustomWorldProfile, + type GameState, +} from '../../types'; +import { PlatformHomeView } from './PlatformHomeView'; +import { PlatformWorldDetailView } from './PlatformWorldDetailView'; + +const CustomWorldGenerationView = lazy(async () => { + const module = await import('../CustomWorldGenerationView'); + return { + default: module.CustomWorldGenerationView, + }; +}); + +const CustomWorldResultView = lazy(async () => { + const module = await import('../CustomWorldResultView'); + return { + default: module.CustomWorldResultView, + }; +}); + +const CustomWorldCreatorModal = lazy(async () => { + const module = await import('../SelectionCustomizationModals'); + return { + default: module.CustomWorldCreatorModal, + }; +}); export type SelectionStage = - | 'start' - | 'world' - | 'custom-world-home' - | 'custom-world-agent'; + | 'platform' + | 'detail' + | 'custom-world-generating' + | 'custom-world-result'; type PreGameSelectionFlowProps = { selectionStage: SelectionStage; setSelectionStage: (stage: SelectionStage) => void; gameState: GameState; hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: () => void; handleStartNewGame: () => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; }; -const DEVELOPER_TEAM_MESSAGE = - '\u7a0b\u7b56\u7f8e\uff1a\u53d9\u4e16AI \u5305\u4ef2\u822a\n\u5408\u4f5c\u8bf7\u8054\u7cfb\u5fae\u4fe1\uff1abzh253518756'; +function buildLockedSeedNameSets(profile: CustomWorldProfile) { + const lockedCharacterNames = new Set( + profile.creatorIntent?.keyCharacters + .filter((entry) => entry.locked) + .map((entry) => entry.name.trim()) + .filter(Boolean) ?? [], + ); + const lockedLandmarkNames = new Set( + profile.creatorIntent?.keyLandmarks + .filter((entry) => entry.locked) + .map((entry) => entry.name.trim()) + .filter(Boolean) ?? [], + ); -const START_SCREEN_CONTACTS = [ - { label: 'QQ群', value: '1094580241' }, - { label: '微信', value: 'bzh253518756' }, -] as const; - -function createOperationErrorBanner( - message: string, -): CustomWorldAgentOperationRecord { return { - operationId: `operation-error-${Date.now()}`, - type: 'process_message', - status: 'failed', - phaseLabel: '处理失败', - phaseDetail: message, - progress: 100, - error: message, + lockedCharacterNames, + lockedLandmarkNames, }; } +function mergeLockedProfileContent( + currentProfile: CustomWorldProfile, + nextProfile: CustomWorldProfile, +) { + const { lockedCharacterNames, lockedLandmarkNames } = + buildLockedSeedNameSets(currentProfile); + + const nextPlayableNpcs = nextProfile.playableNpcs.map((npc) => { + if (!lockedCharacterNames.has(npc.name.trim())) { + return npc; + } + return ( + currentProfile.playableNpcs.find( + (currentNpc) => currentNpc.name.trim() === npc.name.trim(), + ) ?? npc + ); + }); + + const nextStoryNpcs = nextProfile.storyNpcs.map((npc) => { + if (!lockedCharacterNames.has(npc.name.trim())) { + return npc; + } + return ( + currentProfile.storyNpcs.find( + (currentNpc) => currentNpc.name.trim() === npc.name.trim(), + ) ?? npc + ); + }); + + const nextLandmarks = nextProfile.landmarks.map((landmark) => { + if (!lockedLandmarkNames.has(landmark.name.trim())) { + return landmark; + } + return ( + currentProfile.landmarks.find( + (currentLandmark) => + currentLandmark.name.trim() === landmark.name.trim(), + ) ?? landmark + ); + }); + + return { + ...nextProfile, + playableNpcs: nextPlayableNpcs, + storyNpcs: nextStoryNpcs, + landmarks: nextLandmarks, + } satisfies CustomWorldProfile; +} + +function resolveErrorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +function LazyPanelFallback({ label }: { label: string }) { + return ( +
+
+ {label} +
+
+ ); +} + export function PreGameSelectionFlow({ selectionStage, setSelectionStage, - gameState, hasSavedGame, + savedSnapshot, handleContinueGame, handleStartNewGame, handleCustomWorldSelect, }: PreGameSelectionFlowProps) { - const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState< - CustomWorldProfile[] + const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] = + useState(null); + const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState< + CustomWorldLibraryEntry[] >([]); - const [customWorldWorks, setCustomWorldWorks] = useState< - CustomWorldWorkSummary[] + const [publishedGalleryEntries, setPublishedGalleryEntries] = useState< + CustomWorldGalleryCard[] >([]); - const [isLoadingCustomWorldWorks, setIsLoadingCustomWorldWorks] = - useState(false); - const [customWorldWorksError, setCustomWorldWorksError] = - useState(null); - const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false); - const [isCreatingCustomWorldWork, setIsCreatingCustomWorldWork] = - useState(false); - const [isRestoringAgentSession, setIsRestoringAgentSession] = useState(false); - const [activeCustomWorldAgentSessionId, setActiveCustomWorldAgentSessionId] = - useState(null); - const [ - activeCustomWorldAgentOperationId, - setActiveCustomWorldAgentOperationId, - ] = useState(null); - const [activeCustomWorldAgentSession, setActiveCustomWorldAgentSession] = - useState(null); - const [activeCustomWorldAgentOperation, setActiveCustomWorldAgentOperation] = - useState(null); - const clearOperationTimeoutRef = useRef(null); + const [selectedDetailEntry, setSelectedDetailEntry] = + useState | null>(null); + const [showCustomWorldModal, setShowCustomWorldModal] = useState(false); + const [customWorldCreatorIntent, setCustomWorldCreatorIntent] = + useState(() => + createEmptyCustomWorldCreatorIntent('freeform'), + ); + const [customWorldGenerationMode, setCustomWorldGenerationMode] = + useState('fast'); + const [customWorldError, setCustomWorldError] = useState(null); + const [platformError, setPlatformError] = useState(null); + const [detailError, setDetailError] = useState(null); + const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false); + const [isLoadingPlatform, setIsLoadingPlatform] = useState(false); + const [isDetailLoading, setIsDetailLoading] = useState(false); + const [isMutatingDetail, setIsMutatingDetail] = useState(false); + const [customWorldProgress, setCustomWorldProgress] = + useState(null); + const customWorldAbortControllerRef = useRef(null); - const savedCustomWorldCards = useMemo( + const previewCustomWorldCharacters = useMemo( () => - savedCustomWorldProfiles.map((profile) => { - const themeMode = detectCustomWorldThemeMode(profile); - const leadCharacter = - buildCustomWorldPlayableCharacters(profile)[0] ?? null; - - return { - id: profile.id, - profile, - texture: UI_CHROME.panel, - sceneImage: resolveCustomWorldCampSceneImage(profile) ?? '', - featurePortrait: leadCharacter?.portrait ?? '', - featureIcon: - themeMode === 'martial' - ? CUSTOM_WORLD_THEME_ICONS.martial - : themeMode === 'arcane' - ? CUSTOM_WORLD_THEME_ICONS.arcane - : CHROME_ICONS.refreshOptions, - accentLabel: '自定义世界', - }; - }), - [savedCustomWorldProfiles], + generatedCustomWorldProfile + ? buildCustomWorldPlayableCharacters(generatedCustomWorldProfile) + : [], + [generatedCustomWorldProfile], ); - const refreshCustomWorldLibrary = useCallback(async () => { + const featuredGalleryEntries = useMemo( + () => publishedGalleryEntries.slice(0, 6), + [publishedGalleryEntries], + ); + + const refreshPlatformData = useCallback(async () => { + setIsLoadingPlatform(true); + setPlatformError(null); + try { - setSavedCustomWorldProfiles(await listCustomWorldLibrary()); - } catch (error) { - console.warn( - '[PreGameSelectionFlow] failed to load custom world library', - error, - ); - } - }, []); - - const refreshCustomWorldWorks = useCallback(async () => { - setIsLoadingCustomWorldWorks(true); - try { - setCustomWorldWorks(await listCustomWorldWorks()); - setCustomWorldWorksError(null); - } catch (error) { - setCustomWorldWorksError( - error instanceof Error ? error.message : '读取创作作品失败。', - ); - } finally { - setIsLoadingCustomWorldWorks(false); - } - }, []); - - const refreshCustomWorldHomeData = useCallback(async () => { - await Promise.all([refreshCustomWorldLibrary(), refreshCustomWorldWorks()]); - }, [refreshCustomWorldLibrary, refreshCustomWorldWorks]); - - const syncCustomWorldAgentUiState = useCallback( - (sessionId: string | null, operationId: string | null) => { - setActiveCustomWorldAgentSessionId(sessionId); - setActiveCustomWorldAgentOperationId(operationId); - writeCustomWorldAgentUiState({ - activeSessionId: sessionId, - activeOperationId: operationId, - }); - }, - [], - ); - - const scheduleClearOperationBanner = useCallback(() => { - if (clearOperationTimeoutRef.current) { - window.clearTimeout(clearOperationTimeoutRef.current); - } - - clearOperationTimeoutRef.current = window.setTimeout(() => { - setActiveCustomWorldAgentOperation(null); - setActiveCustomWorldAgentOperationId(null); - writeCustomWorldAgentUiState({ - activeSessionId: activeCustomWorldAgentSessionId, - activeOperationId: null, - }); - clearOperationTimeoutRef.current = null; - }, 1400); - }, [activeCustomWorldAgentSessionId]); - - const refreshActiveCustomWorldAgentSession = useCallback( - async (sessionId: string) => { - const session = await getCustomWorldAgentSession(sessionId); - setActiveCustomWorldAgentSession(session); - return session; - }, - [], - ); - - const loadCustomWorldAgentSession = useCallback( - async (sessionId: string, operationId?: string | null) => { - setIsRestoringAgentSession(true); - setActiveCustomWorldAgentSession(null); - try { - const session = await getCustomWorldAgentSession(sessionId); - setActiveCustomWorldAgentSession(session); - setActiveCustomWorldAgentOperation(null); - syncCustomWorldAgentUiState(sessionId, operationId ?? null); - setSelectionStage('custom-world-agent'); - - if (operationId) { - try { - const operation = await getCustomWorldAgentOperation( - sessionId, - operationId, - ); - setActiveCustomWorldAgentOperation(operation); - } catch (error) { - setActiveCustomWorldAgentOperation( - createOperationErrorBanner( - error instanceof Error ? error.message : '读取操作状态失败。', - ), - ); - } - } - } catch (error) { - clearCustomWorldAgentUiState(); - syncCustomWorldAgentUiState(null, null); - setSelectionStage('custom-world-home'); - setCustomWorldWorksError( - error instanceof Error ? error.message : '恢复共创会话失败。', + const [libraryEntries, galleryEntries] = await Promise.all([ + listCustomWorldLibrary(), + listCustomWorldGallery(), + ]); + setSavedCustomWorldEntries(libraryEntries); + setPublishedGalleryEntries(galleryEntries); + if (selectedDetailEntry) { + const nextOwnedEntry = libraryEntries.find( + (entry) => + entry.ownerUserId === selectedDetailEntry.ownerUserId && + entry.profileId === selectedDetailEntry.profileId, ); - } finally { - setIsRestoringAgentSession(false); + if (nextOwnedEntry) { + setSelectedDetailEntry(nextOwnedEntry); + } } - }, - [setSelectionStage, syncCustomWorldAgentUiState], - ); - - const leaveCustomWorldAgentWorkspace = useCallback(async () => { - clearCustomWorldAgentUiState(); - syncCustomWorldAgentUiState(null, null); - setActiveCustomWorldAgentSession(null); - setActiveCustomWorldAgentOperation(null); - setSelectionStage('custom-world-home'); - await refreshCustomWorldHomeData(); - }, [ - refreshCustomWorldHomeData, - setSelectionStage, - syncCustomWorldAgentUiState, - ]); + } catch (error) { + setPlatformError(resolveErrorMessage(error, '读取平台数据失败。')); + } finally { + setIsLoadingPlatform(false); + } + }, [selectedDetailEntry]); useEffect(() => { - void refreshCustomWorldHomeData(); - const restoredState = readCustomWorldAgentUiState(); + let isActive = true; - if (!gameState.worldType && restoredState.activeSessionId) { - void loadCustomWorldAgentSession( - restoredState.activeSessionId, - restoredState.activeOperationId ?? null, - ); - } - }, [ - gameState.worldType, - loadCustomWorldAgentSession, - refreshCustomWorldHomeData, - ]); + void (async () => { + setIsLoadingPlatform(true); + setPlatformError(null); + try { + const [libraryEntries, galleryEntries] = await Promise.all([ + listCustomWorldLibrary(), + listCustomWorldGallery(), + ]); + if (!isActive) { + return; + } + setSavedCustomWorldEntries(libraryEntries); + setPublishedGalleryEntries(galleryEntries); + } catch (error) { + if (!isActive) { + return; + } + setPlatformError(resolveErrorMessage(error, '读取平台数据失败。')); + } finally { + if (isActive) { + setIsLoadingPlatform(false); + } + } + })(); - useEffect(() => { - if (!gameState.worldType && selectionStage === 'custom-world-home') { - void refreshCustomWorldHomeData(); - } - }, [gameState.worldType, refreshCustomWorldHomeData, selectionStage]); + return () => { + isActive = false; + }; + }, []); useEffect(() => { if ( - !activeCustomWorldAgentSessionId || - !activeCustomWorldAgentOperationId || - !activeCustomWorldAgentOperation || - (activeCustomWorldAgentOperation.status !== 'queued' && - activeCustomWorldAgentOperation.status !== 'running') + selectionStage === 'custom-world-result' && + !generatedCustomWorldProfile ) { - return; + setSelectionStage(selectedDetailEntry ? 'detail' : 'platform'); } - - const timeoutId = window.setTimeout(async () => { - try { - const operation = await getCustomWorldAgentOperation( - activeCustomWorldAgentSessionId, - activeCustomWorldAgentOperationId, - ); - setActiveCustomWorldAgentOperation(operation); - - if ( - operation.status === 'completed' || - operation.status === 'failed' - ) { - await refreshActiveCustomWorldAgentSession( - activeCustomWorldAgentSessionId, - ); - await refreshCustomWorldWorks(); - - if (operation.status === 'completed') { - scheduleClearOperationBanner(); - } else { - setActiveCustomWorldAgentOperationId(null); - writeCustomWorldAgentUiState({ - activeSessionId: activeCustomWorldAgentSessionId, - activeOperationId: null, - }); - } - } - } catch (error) { - setActiveCustomWorldAgentOperation( - createOperationErrorBanner( - error instanceof Error ? error.message : '读取操作状态失败。', - ), - ); - } - }, 500); - - return () => window.clearTimeout(timeoutId); }, [ - activeCustomWorldAgentOperation, - activeCustomWorldAgentOperationId, - activeCustomWorldAgentSessionId, - refreshActiveCustomWorldAgentSession, - refreshCustomWorldWorks, - scheduleClearOperationBanner, + generatedCustomWorldProfile, + selectedDetailEntry, + selectionStage, + setSelectionStage, ]); useEffect( () => () => { - if (clearOperationTimeoutRef.current) { - window.clearTimeout(clearOperationTimeoutRef.current); - } + customWorldAbortControllerRef.current?.abort(); }, [], ); + const customWorldSettingPreview = useMemo(() => { + if (customWorldCreatorIntent.sourceMode === 'freeform') { + return customWorldCreatorIntent.rawSettingText.trim(); + } + + const intentSummary = buildCustomWorldCreatorIntentDisplayText( + customWorldCreatorIntent, + ).trim(); + if (intentSummary) { + return intentSummary; + } + + return customWorldCreatorIntent.rawSettingText.trim(); + }, [customWorldCreatorIntent]); + + const leaveCustomWorldResult = () => { + setGeneratedCustomWorldProfile(null); + setCustomWorldError(null); + setCustomWorldProgress(null); + setSelectionStage(selectedDetailEntry ? 'detail' : 'platform'); + }; + + const leaveCustomWorldGeneration = () => { + if (isGeneratingCustomWorld) { + return; + } + + setCustomWorldError(null); + setCustomWorldProgress(null); + setSelectionStage('platform'); + }; + const openCustomWorldCreator = () => { - setSelectionStage('custom-world-home'); - }; - - const startNewGame = () => { - handleStartNewGame(); - clearCustomWorldAgentUiState(); - syncCustomWorldAgentUiState(null, null); - setActiveCustomWorldAgentSession(null); - setActiveCustomWorldAgentOperation(null); - setSelectionStage('world'); - }; - - const handleCreateNewWork = async () => { - if (isCreatingCustomWorldWork) { + if (isGeneratingCustomWorld) { return; } - setIsCreatingCustomWorldWork(true); - setCustomWorldWorksError(null); - try { - const response = await createCustomWorldAgentSession({}); + if (!hasSavedGame) { + handleStartNewGame(); + } - setActiveCustomWorldAgentSession(response.session); - setActiveCustomWorldAgentOperation(null); - syncCustomWorldAgentUiState(response.session.sessionId, null); - setSelectionStage('custom-world-agent'); - await refreshCustomWorldWorks(); - } catch (error) { - setCustomWorldWorksError( - error instanceof Error ? error.message : '创建共创会话失败。', + setGeneratedCustomWorldProfile(null); + setSelectedDetailEntry(null); + setPlatformError(null); + setDetailError(null); + setCustomWorldError(null); + setCustomWorldProgress(null); + setCustomWorldCreatorIntent(createEmptyCustomWorldCreatorIntent('freeform')); + setCustomWorldGenerationMode('fast'); + setShowCustomWorldModal(true); + }; + + const editCustomWorldSetting = () => { + if (isGeneratingCustomWorld) { + return; + } + + if (generatedCustomWorldProfile) { + setCustomWorldCreatorIntent( + generatedCustomWorldProfile.creatorIntent ?? + ({ + ...createEmptyCustomWorldCreatorIntent('freeform'), + rawSettingText: generatedCustomWorldProfile.settingText, + } satisfies CustomWorldCreatorIntent), ); + setCustomWorldGenerationMode( + generatedCustomWorldProfile.generationMode ?? 'full', + ); + } + + setCustomWorldError(null); + setCustomWorldProgress(null); + setShowCustomWorldModal(true); + }; + + const openLibraryDetail = ( + entry: CustomWorldLibraryEntry, + ) => { + setSelectedDetailEntry(entry); + setDetailError(null); + setSelectionStage('detail'); + }; + + const openGalleryDetail = async (entry: CustomWorldGalleryCard) => { + setSelectionStage('detail'); + setIsDetailLoading(true); + setDetailError(null); + try { + const detailEntry = await getCustomWorldGalleryDetail( + entry.ownerUserId, + entry.profileId, + ); + setSelectedDetailEntry(detailEntry); + } catch (error) { + setSelectedDetailEntry(null); + setDetailError(resolveErrorMessage(error, '读取作品详情失败。')); } finally { - setIsCreatingCustomWorldWork(false); + setIsDetailLoading(false); } }; - const handleResumeDraft = async (sessionId: string) => { - await loadCustomWorldAgentSession(sessionId, null); - }; - - const handleEnterPublished = async (profileId: string) => { - let profile = - savedCustomWorldProfiles.find((item) => item.id === profileId) ?? null; - - if (!profile) { - const profiles = await listCustomWorldLibrary(); - setSavedCustomWorldProfiles(profiles); - profile = profiles.find((item) => item.id === profileId) ?? null; - } - - if (!profile) { - setCustomWorldWorksError('读取已发布世界失败。'); - return; - } - - handleCustomWorldSelect(profile); - }; - - const handleSubmitCustomWorldAgentMessage = async ( - payload: SendCustomWorldAgentMessageRequest, - ) => { - if (!activeCustomWorldAgentSessionId) { + const saveGeneratedCustomWorld = async () => { + if (!generatedCustomWorldProfile) { return; } try { - const response = await sendCustomWorldAgentMessage( - activeCustomWorldAgentSessionId, - payload, - ); - setActiveCustomWorldAgentOperation(response.operation); - syncCustomWorldAgentUiState( - activeCustomWorldAgentSessionId, - response.operation.operationId, - ); - await refreshActiveCustomWorldAgentSession(activeCustomWorldAgentSessionId); + const mutation = await upsertCustomWorldProfile(generatedCustomWorldProfile); + setSavedCustomWorldEntries(mutation.entries); + setSelectedDetailEntry(mutation.entry); + await refreshPlatformData(); + setGeneratedCustomWorldProfile(null); + setCustomWorldError(null); + setCustomWorldProgress(null); + setSelectionStage('platform'); } catch (error) { - setActiveCustomWorldAgentOperation( - createOperationErrorBanner( - error instanceof Error ? error.message : '发送共创消息失败。', - ), - ); + setCustomWorldError(resolveErrorMessage(error, '保存自定义世界失败。')); } }; - const handleExecuteCustomWorldAgentAction = async ( - payload: CustomWorldAgentActionRequest, + const openSavedCustomWorldEditor = ( + entry: CustomWorldLibraryEntry, ) => { - if (!activeCustomWorldAgentSessionId) { + if (isGeneratingCustomWorld) { return; } - try { - const response = await executeCustomWorldAgentAction( - activeCustomWorldAgentSessionId, - payload, - ); - setActiveCustomWorldAgentOperation(response.operation); - syncCustomWorldAgentUiState( - activeCustomWorldAgentSessionId, - response.operation.operationId, - ); - await refreshActiveCustomWorldAgentSession(activeCustomWorldAgentSessionId); - } catch (error) { - setActiveCustomWorldAgentOperation( - createOperationErrorBanner( - error instanceof Error ? error.message : '执行共创动作失败。', - ), - ); - } + setSelectedDetailEntry(entry); + setGeneratedCustomWorldProfile(entry.profile); + setCustomWorldCreatorIntent( + entry.profile.creatorIntent ?? + ({ + ...createEmptyCustomWorldCreatorIntent('freeform'), + rawSettingText: entry.profile.settingText, + } satisfies CustomWorldCreatorIntent), + ); + setCustomWorldGenerationMode(entry.profile.generationMode ?? 'full'); + setCustomWorldError(null); + setCustomWorldProgress(null); + setSelectionStage('custom-world-result'); }; - const handleRefreshCustomWorldAgentSession = async () => { - if (!activeCustomWorldAgentSessionId) { + const regenerateFromCurrentProfile = async ( + applyProfile: ( + currentProfile: CustomWorldProfile, + regeneratedProfile: CustomWorldProfile, + ) => CustomWorldProfile, + options: { + confirmMessage: string; + generationMode?: CustomWorldGenerationMode; + }, + ) => { + if (!generatedCustomWorldProfile || isGeneratingCustomWorld) { return; } - try { - await refreshActiveCustomWorldAgentSession(activeCustomWorldAgentSessionId); + const confirmed = window.confirm(options.confirmMessage); + if (!confirmed) { + return; + } - if (activeCustomWorldAgentOperationId) { - const operation = await getCustomWorldAgentOperation( - activeCustomWorldAgentSessionId, - activeCustomWorldAgentOperationId, - ); - setActiveCustomWorldAgentOperation(operation); + const abortController = new AbortController(); + customWorldAbortControllerRef.current?.abort(); + customWorldAbortControllerRef.current = abortController; + setIsGeneratingCustomWorld(true); + setCustomWorldError(null); + + try { + const regeneratedProfile = await generateCustomWorldProfile( + { + settingText: + generatedCustomWorldProfile.settingText.trim() || + customWorldSettingPreview, + creatorIntent: + (generatedCustomWorldProfile.creatorIntent as JsonObject | null) ?? + null, + generationMode: + options.generationMode ?? + generatedCustomWorldProfile.generationMode ?? + 'full', + }, + { + signal: abortController.signal, + onProgress: setCustomWorldProgress, + }, + ); + + if (abortController.signal.aborted) { + return; } - } catch (error) { - setActiveCustomWorldAgentOperation( - createOperationErrorBanner( - error instanceof Error ? error.message : '刷新会话失败。', + + const mergedProfile = applyProfile( + generatedCustomWorldProfile, + mergeLockedProfileContent( + generatedCustomWorldProfile, + regeneratedProfile, ), ); + + setGeneratedCustomWorldProfile({ + ...mergedProfile, + id: generatedCustomWorldProfile.id, + }); + setCustomWorldProgress(null); + setCustomWorldError(null); + } catch (error) { + if (abortController.signal.aborted) { + setCustomWorldError('世界生成已中断。你可以重新尝试本次操作。'); + return; + } + setCustomWorldError(resolveErrorMessage(error, '局部重生成失败。')); + } finally { + if (customWorldAbortControllerRef.current === abortController) { + customWorldAbortControllerRef.current = null; + } + setIsGeneratingCustomWorld(false); } }; + const continueExpandCustomWorld = async () => { + await regenerateFromCurrentProfile( + (_currentProfile, regeneratedProfile) => ({ + ...regeneratedProfile, + generationMode: 'full', + generationStatus: 'complete', + }), + { + confirmMessage: + '确认继续补全当前世界吗?系统会在保留已锁定锚点的前提下,继续生成长尾角色和场景网络。', + generationMode: 'full', + }, + ); + }; + + const createCustomWorld = async () => { + if (isGeneratingCustomWorld) { + return; + } + + const generationText = + buildCustomWorldCreatorIntentGenerationText( + customWorldCreatorIntent, + ).trim() || customWorldCreatorIntent.rawSettingText.trim(); + const settingText = customWorldSettingPreview.trim() || generationText; + + if (!generationText) { + setCustomWorldError( + customWorldCreatorIntent.sourceMode === 'card' + ? '请至少填写一个世界锚点。' + : '请先输入世界设置。', + ); + return; + } + + const abortController = new AbortController(); + customWorldAbortControllerRef.current?.abort(); + customWorldAbortControllerRef.current = abortController; + setCustomWorldError(null); + setCustomWorldProgress(null); + setShowCustomWorldModal(false); + setSelectionStage('custom-world-generating'); + setIsGeneratingCustomWorld(true); + + try { + const profile = await generateCustomWorldProfile( + { + settingText, + creatorIntent: customWorldCreatorIntent as unknown as JsonObject, + generationMode: customWorldGenerationMode, + }, + { + signal: abortController.signal, + onProgress: setCustomWorldProgress, + }, + ); + + if (abortController.signal.aborted) { + return; + } + + setGeneratedCustomWorldProfile( + generatedCustomWorldProfile + ? { + ...profile, + id: generatedCustomWorldProfile.id, + } + : profile, + ); + setCustomWorldProgress(null); + setCustomWorldError(null); + setSelectionStage('custom-world-result'); + } catch (error) { + if (abortController.signal.aborted) { + setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。'); + return; + } + setCustomWorldError(resolveErrorMessage(error, '生成自定义世界失败。')); + } finally { + if (customWorldAbortControllerRef.current === abortController) { + customWorldAbortControllerRef.current = null; + } + setIsGeneratingCustomWorld(false); + } + }; + + const interruptCustomWorldGeneration = () => { + if (!isGeneratingCustomWorld || !customWorldAbortControllerRef.current) { + return; + } + + const confirmed = window.confirm( + '确认中断当前世界生成吗?本轮未完成的内容不会保留。', + ); + if (!confirmed) { + return; + } + + customWorldAbortControllerRef.current.abort(new Error('世界生成已中断。')); + }; + + const handleStartSelectedWorld = () => { + if (!selectedDetailEntry) { + return; + } + + handleCustomWorldSelect(selectedDetailEntry.profile); + }; + + const handlePublishSelectedWorld = async () => { + if (!selectedDetailEntry || isMutatingDetail) { + return; + } + + setIsMutatingDetail(true); + setDetailError(null); + try { + const mutation = await publishCustomWorldProfile( + selectedDetailEntry.profileId, + ); + setSavedCustomWorldEntries(mutation.entries); + setSelectedDetailEntry(mutation.entry); + setPublishedGalleryEntries(await listCustomWorldGallery()); + } catch (error) { + setDetailError(resolveErrorMessage(error, '发布自定义世界失败。')); + } finally { + setIsMutatingDetail(false); + } + }; + + const handleUnpublishSelectedWorld = async () => { + if (!selectedDetailEntry || isMutatingDetail) { + return; + } + + setIsMutatingDetail(true); + setDetailError(null); + try { + const mutation = await unpublishCustomWorldProfile( + selectedDetailEntry.profileId, + ); + setSavedCustomWorldEntries(mutation.entries); + setSelectedDetailEntry(mutation.entry); + setPublishedGalleryEntries(await listCustomWorldGallery()); + } catch (error) { + setDetailError(resolveErrorMessage(error, '下架自定义世界失败。')); + } finally { + setIsMutatingDetail(false); + } + }; + + const isSelectedWorldOwned = Boolean( + selectedDetailEntry && + savedCustomWorldEntries.some( + (entry) => + entry.ownerUserId === selectedDetailEntry.ownerUserId && + entry.profileId === selectedDetailEntry.profileId, + ), + ); + return ( <> - {!gameState.worldType && selectionStage === 'start' && ( + {selectionStage === 'platform' && ( -
-
-
- {hasSavedGame ? ( - - ) : null} - - - - -
-
- -
-
- 联系方式 -
-
- {START_SCREEN_CONTACTS.map((contact) => ( -
- {contact.label} - - {contact.value} - -
- ))} -
-
-
-
- )} - - {!gameState.worldType && selectionStage === 'world' && ( - -
-
- 自定义世界 -
- -
- -
-
- - - {savedCustomWorldCards.map((world) => ( - - ))} -
-
-
- )} - - {!gameState.worldType && selectionStage === 'custom-world-home' && ( - - setSelectionStage('world')} - onRetry={() => { - void refreshCustomWorldHomeData(); - }} - onCreateNew={() => { - void handleCreateNewWork(); - }} - onResumeDraft={(sessionId) => { - void handleResumeDraft(sessionId); - }} - onEnterPublished={(profileId) => { - void handleEnterPublished(profileId); - }} - /> - - )} - - {!gameState.worldType && selectionStage === 'custom-world-agent' && ( - - { - void leaveCustomWorldAgentWorkspace(); - }} + { - void handleRefreshCustomWorldAgentSession(); + void refreshPlatformData(); }} - onSubmitMessage={(payload) => { - void handleSubmitCustomWorldAgentMessage(payload); - }} - onExecuteAction={(payload) => { - void handleExecuteCustomWorldAgentAction(payload); + onOpenCreateWorld={openCustomWorldCreator} + onOpenGalleryDetail={(entry) => { + void openGalleryDetail(entry); }} + onOpenLibraryDetail={openLibraryDetail} /> )} + + {selectionStage === 'detail' && ( + + {isDetailLoading || !selectedDetailEntry ? ( +
+
+ {detailError || '正在读取作品详情...'} +
+
+ ) : ( + { + setDetailError(null); + setSelectionStage('platform'); + }} + onStartGame={handleStartSelectedWorld} + onContinueEdit={ + isSelectedWorldOwned + ? () => openSavedCustomWorldEditor(selectedDetailEntry) + : null + } + onPublish={ + selectedDetailEntry.visibility === 'draft' && + isSelectedWorldOwned + ? handlePublishSelectedWorld + : null + } + onUnpublish={ + selectedDetailEntry.visibility === 'published' && + isSelectedWorldOwned + ? handleUnpublishSelectedWorld + : null + } + /> + )} +
+ )} + + {selectionStage === 'custom-world-generating' && ( + + + } + > + { + void createCustomWorld(); + }} + onInterrupt={interruptCustomWorldGeneration} + /> + + + )} + + {selectionStage === 'custom-world-result' && + generatedCustomWorldProfile && ( + + } + > + { + void createCustomWorld(); + }} + onContinueExpand={() => { + void continueExpandCustomWorld(); + }} + onSave={() => { + void saveGeneratedCustomWorld(); + }} + /> + + + )}
- setShowDeveloperTeamModal(false)} - /> + {showCustomWorldModal ? ( + + { + setCustomWorldCreatorIntent(value); + if (customWorldError) { + setCustomWorldError(null); + } + }} + generationMode={customWorldGenerationMode} + onGenerationModeChange={setCustomWorldGenerationMode} + onClose={() => { + if (isGeneratingCustomWorld) { + return; + } + setShowCustomWorldModal(false); + }} + onSubmit={() => { + void createCustomWorld(); + }} + isGenerating={isGeneratingCustomWorld} + progress={customWorldProgress?.overallProgress ?? 0} + progressLabel={customWorldProgress?.phaseLabel ?? '正在准备生成'} + error={customWorldError} + /> + + ) : null} ); } diff --git a/src/components/game-shell/platformWorldPresentation.ts b/src/components/game-shell/platformWorldPresentation.ts new file mode 100644 index 00000000..639d0184 --- /dev/null +++ b/src/components/game-shell/platformWorldPresentation.ts @@ -0,0 +1,90 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, +} from '../../../packages/shared/src/contracts/runtime'; +import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; +import type { CustomWorldProfile } from '../../types'; + +export type PlatformWorldCardLike = + | CustomWorldGalleryCard + | CustomWorldLibraryEntry; + +export function isLibraryWorldEntry( + entry: PlatformWorldCardLike, +): entry is CustomWorldLibraryEntry { + return 'profile' in entry; +} + +export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) { + if (entry.coverImageSrc) { + return entry.coverImageSrc; + } + + if (isLibraryWorldEntry(entry)) { + return resolveCustomWorldCampSceneImage(entry.profile) ?? ''; + } + + return ''; +} + +export function resolvePlatformWorldLeadPortrait( + entry: PlatformWorldCardLike, +) { + if (!isLibraryWorldEntry(entry)) { + return ''; + } + + return buildCustomWorldPlayableCharacters(entry.profile)[0]?.portrait ?? ''; +} + +export function buildPlatformWorldTags(entry: PlatformWorldCardLike) { + if (!isLibraryWorldEntry(entry)) { + return [ + describePlatformThemeLabel(entry.themeMode), + `${entry.playableNpcCount} 角色`, + `${entry.landmarkCount} 地标`, + ]; + } + + return [ + ...entry.profile.majorFactions.slice(0, 2), + ...entry.profile.coreConflicts.slice(0, 1), + ] + .map((value) => value.trim()) + .filter(Boolean) + .slice(0, 3); +} + +export function formatPlatformWorldTime(value: string | null) { + if (!value) { + return '未发布'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleDateString('zh-CN', { + month: '2-digit', + day: '2-digit', + }); +} + +export function describePlatformThemeLabel(themeMode: PlatformWorldCardLike['themeMode']) { + switch (themeMode) { + case 'martial': + return '江湖'; + case 'arcane': + return '灵脉'; + case 'machina': + return '机巧'; + case 'tide': + return '潮痕'; + case 'rift': + return '裂界'; + default: + return '回响'; + } +} diff --git a/src/components/game-shell/types.ts b/src/components/game-shell/types.ts index 395bbb44..c24944f2 100644 --- a/src/components/game-shell/types.ts +++ b/src/components/game-shell/types.ts @@ -7,10 +7,11 @@ import type { QuestFlowUi, StoryGenerationNpcUi, } from '../../hooks/useStoryGeneration'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { Character, - CustomWorldProfile, CompanionRenderState, + CustomWorldProfile, GameState, StoryMoment, StoryOption, @@ -43,6 +44,7 @@ export interface GameShellStoryProps { export interface GameShellEntryProps { hasSavedGame: boolean; + savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: () => void; handleStartNewGame: () => void; handleSaveAndExit: () => void; diff --git a/src/components/game-shell/useGameShellRuntimeViewModel.ts b/src/components/game-shell/useGameShellRuntimeViewModel.ts index cb18016c..01b88ae9 100644 --- a/src/components/game-shell/useGameShellRuntimeViewModel.ts +++ b/src/components/game-shell/useGameShellRuntimeViewModel.ts @@ -153,7 +153,7 @@ export function useGameShellRuntimeViewModel(params: Pick< const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; const hideSelectionHero = gameState.currentScene === 'Selection' && - shellViewModel.selectionStage !== 'start'; + shellViewModel.selectionStage !== 'platform'; const dialogueIndicator = useMemo( () => diff --git a/src/components/game-shell/useGameShellViewModel.ts b/src/components/game-shell/useGameShellViewModel.ts index 70e70948..dcecf9c6 100644 --- a/src/components/game-shell/useGameShellViewModel.ts +++ b/src/components/game-shell/useGameShellViewModel.ts @@ -30,7 +30,7 @@ export function useGameShellViewModel(params: { characterChatModalOpen, hasNpcModalOpen, } = params; - const [selectionStage, setSelectionStage] = useState('start'); + const [selectionStage, setSelectionStage] = useState('platform'); const [overlayPanel, setOverlayPanel] = useState(null); const [selectedSceneEntity, setSelectedSceneEntity] = useState(null); const [showTeamModal, setShowTeamModal] = useState(false); @@ -56,13 +56,13 @@ export function useGameShellViewModel(params: { const openCampModal = () => setShowTeamModal(true); const closeCampModal = () => setShowTeamModal(false); - const resetSelectionFlow = () => setSelectionStage('start'); + const resetSelectionFlow = () => setSelectionStage('platform'); const resetForSaveAndExit = () => { setSelectedSceneEntity(null); setOverlayPanel(null); setShowTeamModal(false); - setSelectionStage('start'); + setSelectionStage('platform'); }; return { diff --git a/src/hooks/useGamePersistence.ts b/src/hooks/useGamePersistence.ts index 22108055..4ff35092 100644 --- a/src/hooks/useGamePersistence.ts +++ b/src/hooks/useGamePersistence.ts @@ -8,8 +8,8 @@ import { putSaveSnapshot, } from '../services/storageService'; import type { GameState, StoryMoment } from '../types'; -import type { BottomTab } from './useGameFlow'; import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator'; +import type { BottomTab } from './useGameFlow'; const AUTO_SAVE_DELAY_MS = 400; @@ -296,6 +296,7 @@ export function useGamePersistence({ return { hasSavedGame, + savedSnapshot, isHydratingSnapshot, isPersistingSnapshot, persistenceError, diff --git a/src/hooks/useGameShellRuntime.ts b/src/hooks/useGameShellRuntime.ts index 7a304f84..08c500f1 100644 --- a/src/hooks/useGameShellRuntime.ts +++ b/src/hooks/useGameShellRuntime.ts @@ -158,6 +158,7 @@ export function useGameShellRuntime(): GameShellProps { }, entry: { hasSavedGame: persistence.hasSavedGame, + savedSnapshot: persistence.savedSnapshot, handleContinueGame, handleStartNewGame, handleSaveAndExit, diff --git a/src/services/storageService.ts b/src/services/storageService.ts index b843ea44..b0a9f2d0 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -3,6 +3,10 @@ import type { } from '../../packages/shared/src/contracts/customWorldAgent'; import type { BasicOkResult, + CustomWorldGalleryDetailResponse, + CustomWorldGalleryResponse, + CustomWorldLibraryEntry, + CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, RuntimeSettings, } from '../../packages/shared/src/contracts/runtime'; @@ -125,7 +129,7 @@ export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {} options, ); - return Array.isArray(response?.profiles) ? response.profiles : []; + return Array.isArray(response?.entries) ? response.entries : []; } export async function listCustomWorldWorks(options: RuntimeRequestOptions = {}) { @@ -143,7 +147,7 @@ export async function upsertCustomWorldProfile( profile: CustomWorldProfile, options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson>( + const response = await requestRuntimeJson>( `/custom-world-library/${encodeURIComponent(profile.id)}`, { method: 'PUT', @@ -156,7 +160,10 @@ export async function upsertCustomWorldProfile( options, ); - return Array.isArray(response?.profiles) ? response.profiles : []; + return { + entry: response.entry, + entries: Array.isArray(response?.entries) ? response.entries : [], + }; } export async function deleteCustomWorldProfile( @@ -170,7 +177,67 @@ export async function deleteCustomWorldProfile( options, ); - return Array.isArray(response?.profiles) ? response.profiles : []; + return Array.isArray(response?.entries) ? response.entries : []; +} + +export async function publishCustomWorldProfile( + profileId: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson>( + `/custom-world-library/${encodeURIComponent(profileId)}/publish`, + { method: 'POST' }, + '发布自定义世界失败', + options, + ); + + return { + entry: response.entry, + entries: Array.isArray(response?.entries) ? response.entries : [], + }; +} + +export async function unpublishCustomWorldProfile( + profileId: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson>( + `/custom-world-library/${encodeURIComponent(profileId)}/unpublish`, + { method: 'POST' }, + '下架自定义世界失败', + options, + ); + + return { + entry: response.entry, + entries: Array.isArray(response?.entries) ? response.entries : [], + }; +} + +export async function listCustomWorldGallery(options: RuntimeRequestOptions = {}) { + const response = await requestRuntimeJson( + '/custom-world-gallery', + { method: 'GET' }, + '读取作品广场失败', + options, + ); + + return Array.isArray(response?.entries) ? response.entries : []; +} + +export async function getCustomWorldGalleryDetail( + ownerUserId: string, + profileId: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson>( + `/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`, + { method: 'GET' }, + '读取作品详情失败', + options, + ); + + return response.entry; } export const runtimeStorageClient = { @@ -183,4 +250,10 @@ export const runtimeStorageClient = { listCustomWorldWorks, upsertCustomWorldProfile, deleteCustomWorldProfile, + publishCustomWorldProfile, + unpublishCustomWorldProfile, + listCustomWorldGallery, + getCustomWorldGalleryDetail, }; + +export type { CustomWorldLibraryEntry }; diff --git a/vite.config.ts b/vite.config.ts index 906c0af4..ad6631ba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,7 +38,7 @@ export default defineConfig(({mode}) => { entries: ['index.html'], }, build: { - chunkSizeWarningLimit: 750, + chunkSizeWarningLimit: 800, }, server: { // HMR is disabled in AI Studio via DISABLE_HMR env var.