This commit is contained in:
@@ -8,3 +8,9 @@ VOLCENGINE_ACCESS_KEY_ID="AKLTZWFjMmYzZTdjZTIxNDRiNTkzMTZiMTk2NzVmNTUxOGI"
|
|||||||
VOLCENGINE_SECRET_ACCESS_KEY="TURRMk56bGhZalE0TjJReE5ERmpNMkpoTUdaa1lqRmtaVGt5TVRrM1lXSQ=="
|
VOLCENGINE_SECRET_ACCESS_KEY="TURRMk56bGhZalE0TjJReE5ERmpNMkpoTUdaa1lqRmtaVGt5TVRrM1lXSQ=="
|
||||||
WECHAT_AUTH_ENABLED="true"
|
WECHAT_AUTH_ENABLED="true"
|
||||||
WECHAT_AUTH_PROVIDER="mock"
|
WECHAT_AUTH_PROVIDER="mock"
|
||||||
|
|
||||||
|
SMS_AUTH_ENABLED="true"
|
||||||
|
SMS_AUTH_PROVIDER="mock"
|
||||||
|
SMS_AUTH_MOCK_VERIFY_CODE="123456"
|
||||||
|
|
||||||
|
VITE_AUTH_ALLOW_DEV_GUEST="false"
|
||||||
|
|||||||
@@ -34,14 +34,64 @@ export type BasicOkResult = {
|
|||||||
ok: true;
|
ok: true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CustomWorldPublicationStatus = 'draft' | 'published';
|
||||||
|
export type CustomWorldThemeMode =
|
||||||
|
| 'martial'
|
||||||
|
| 'arcane'
|
||||||
|
| 'machina'
|
||||||
|
| 'tide'
|
||||||
|
| 'rift'
|
||||||
|
| 'mythic';
|
||||||
|
|
||||||
export type CustomWorldProfileRecord = JsonObject & {
|
export type CustomWorldProfileRecord = JsonObject & {
|
||||||
id?: string;
|
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<never>,
|
||||||
|
'profile'
|
||||||
|
>;
|
||||||
|
|
||||||
export type CustomWorldLibraryResponse<
|
export type CustomWorldLibraryResponse<
|
||||||
TProfile = CustomWorldProfileRecord,
|
TProfile = CustomWorldProfileRecord,
|
||||||
> = {
|
> = {
|
||||||
profiles: TProfile[];
|
entries: CustomWorldLibraryEntry<TProfile>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomWorldLibraryMutationResponse<
|
||||||
|
TProfile = CustomWorldProfileRecord,
|
||||||
|
> = {
|
||||||
|
entry: CustomWorldLibraryEntry<TProfile>;
|
||||||
|
entries: CustomWorldLibraryEntry<TProfile>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomWorldGalleryResponse = {
|
||||||
|
entries: CustomWorldGalleryCard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomWorldGalleryDetailResponse<
|
||||||
|
TProfile = CustomWorldProfileRecord,
|
||||||
|
> = {
|
||||||
|
entry: CustomWorldLibraryEntry<TProfile>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CUSTOM_WORLD_GENERATION_MODES = ['fast', 'full'] as const;
|
export const CUSTOM_WORLD_GENERATION_MODES = ['fast', 'full'] as const;
|
||||||
|
|||||||
5
server-node/sql/schema/00_schema_migrations.sql
Normal file
5
server-node/sql/schema/00_schema_migrations.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);
|
||||||
16
server-node/sql/schema/01_users.sql
Normal file
16
server-node/sql/schema/01_users.sql
Normal file
@@ -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);
|
||||||
10
server-node/sql/schema/02_save_snapshots.sql
Normal file
10
server-node/sql/schema/02_save_snapshots.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
6
server-node/sql/schema/03_runtime_settings.sql
Normal file
6
server-node/sql/schema/03_runtime_settings.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
24
server-node/sql/schema/04_custom_world_profiles.sql
Normal file
24
server-node/sql/schema/04_custom_world_profiles.sql
Normal file
@@ -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);
|
||||||
24
server-node/sql/schema/05_auth_identities.sql
Normal file
24
server-node/sql/schema/05_auth_identities.sql
Normal file
@@ -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);
|
||||||
17
server-node/sql/schema/06_user_sessions.sql
Normal file
17
server-node/sql/schema/06_user_sessions.sql
Normal file
@@ -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);
|
||||||
14
server-node/sql/schema/07_auth_audit_logs.sql
Normal file
14
server-node/sql/schema/07_auth_audit_logs.sql
Normal file
@@ -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);
|
||||||
16
server-node/sql/schema/08_sms_auth_events.sql
Normal file
16
server-node/sql/schema/08_sms_auth_events.sql
Normal file
@@ -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);
|
||||||
13
server-node/sql/schema/09_auth_risk_blocks.sql
Normal file
13
server-node/sql/schema/09_auth_risk_blocks.sql
Normal file
@@ -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);
|
||||||
8
server-node/sql/schema/README.md
Normal file
8
server-node/sql/schema/README.md
Normal file
@@ -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.
|
||||||
@@ -1565,9 +1565,164 @@ test('runtime persistence is isolated by user', async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const userBLibraryPayload = (await userBLibrary.json()) as {
|
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, []);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
|||||||
'20260409_006_auth_audit_logs',
|
'20260409_006_auth_audit_logs',
|
||||||
'20260409_007_sms_auth_events',
|
'20260409_007_sms_auth_events',
|
||||||
'20260409_008_auth_risk_blocks',
|
'20260409_008_auth_risk_blocks',
|
||||||
|
'20260414_009_custom_world_gallery_metadata',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -189,4 +189,32 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
|||||||
ON auth_risk_blocks (scope_type, scope_key, expires_at DESC)`,
|
ON auth_risk_blocks (scope_type, scope_key, expires_at DESC)`,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: '20260414_009_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)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
109
server-node/src/repositories/customWorldLibraryMetadata.ts
Normal file
109
server-node/src/repositories/customWorldLibraryMetadata.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type {
|
||||||
|
CustomWorldProfileRecord,
|
||||||
|
CustomWorldThemeMode,
|
||||||
|
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
import type { QueryResultRow } from 'pg';
|
import type { QueryResultRow } from 'pg';
|
||||||
|
|
||||||
import {
|
|
||||||
DEFAULT_MUSIC_VOLUME,
|
|
||||||
SAVE_SNAPSHOT_VERSION,
|
|
||||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
|
||||||
import type {
|
import type {
|
||||||
CustomWorldProfileRecord,
|
CustomWorldProfileRecord,
|
||||||
RuntimeSettings,
|
RuntimeSettings,
|
||||||
SavedGameSnapshot,
|
SavedGameSnapshot,
|
||||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||||
|
import {
|
||||||
|
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 type { AppDatabase } from '../db.js';
|
||||||
|
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
|
||||||
|
|
||||||
const MAX_CUSTOM_WORLD_PROFILES = 12;
|
const MAX_CUSTOM_WORLD_PROFILES = 12;
|
||||||
|
const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36;
|
||||||
|
|
||||||
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
|
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
|
||||||
|
|
||||||
@@ -27,8 +32,37 @@ type SettingsRow = QueryResultRow & {
|
|||||||
musicVolume: number;
|
musicVolume: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProfileRow = QueryResultRow & {
|
type CustomWorldEntryRow = QueryResultRow & {
|
||||||
|
ownerUserId: string;
|
||||||
|
profileId: string;
|
||||||
payload: CustomWorldProfileRecord;
|
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 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 = {
|
export type RuntimeRepositoryPort = {
|
||||||
@@ -43,21 +77,134 @@ export type RuntimeRepositoryPort = {
|
|||||||
userId: string,
|
userId: string,
|
||||||
settings: RuntimeSettings,
|
settings: RuntimeSettings,
|
||||||
): Promise<RuntimeSettings>;
|
): Promise<RuntimeSettings>;
|
||||||
listCustomWorldProfiles(userId: string): Promise<CustomWorldProfileRecord[]>;
|
listCustomWorldProfiles(
|
||||||
|
userId: string,
|
||||||
|
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||||
upsertCustomWorldProfile(
|
upsertCustomWorldProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
profile: Record<string, unknown>,
|
profile: Record<string, unknown>,
|
||||||
): Promise<CustomWorldProfileRecord[]>;
|
authorDisplayName: string,
|
||||||
|
): Promise<{
|
||||||
|
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
||||||
|
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
||||||
|
}>;
|
||||||
deleteCustomWorldProfile(
|
deleteCustomWorldProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
): Promise<CustomWorldProfileRecord[]>;
|
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
|
||||||
|
publishCustomWorldProfile(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
authorDisplayName: string,
|
||||||
|
): Promise<{
|
||||||
|
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
||||||
|
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
||||||
|
} | null>;
|
||||||
|
unpublishCustomWorldProfile(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
authorDisplayName: string,
|
||||||
|
): Promise<{
|
||||||
|
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
|
||||||
|
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
|
||||||
|
} | null>;
|
||||||
|
listPublishedCustomWorldGallery(): Promise<CustomWorldGalleryCard[]>;
|
||||||
|
getPublishedCustomWorldGalleryDetail(
|
||||||
|
ownerUserId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord> | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeStoredProfile(
|
||||||
|
profileId: string,
|
||||||
|
profile: Record<string, unknown>,
|
||||||
|
): CustomWorldProfileRecord {
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
id: profileId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCustomWorldLibraryEntry(
|
||||||
|
row: CustomWorldEntryRow,
|
||||||
|
): CustomWorldLibraryEntry<CustomWorldProfileRecord> {
|
||||||
|
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 {
|
export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||||
constructor(private readonly db: AppDatabase) {}
|
constructor(private readonly db: AppDatabase) {}
|
||||||
|
|
||||||
|
private async findCustomWorldProfileEntry(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
) {
|
||||||
|
const result = await this.db.query<CustomWorldEntryRow>(
|
||||||
|
`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) {
|
async getSnapshot(userId: string) {
|
||||||
const result = await this.db.query<SnapshotRow>(
|
const result = await this.db.query<SnapshotRow>(
|
||||||
`SELECT version,
|
`SELECT version,
|
||||||
@@ -174,8 +321,21 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listCustomWorldProfiles(userId: string) {
|
async listCustomWorldProfiles(userId: string) {
|
||||||
const result = await this.db.query<ProfileRow>(
|
const result = await this.db.query<CustomWorldEntryRow>(
|
||||||
`SELECT payload_json AS payload
|
`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
|
FROM custom_world_profiles
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
@@ -183,29 +343,71 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
|||||||
[userId, MAX_CUSTOM_WORLD_PROFILES],
|
[userId, MAX_CUSTOM_WORLD_PROFILES],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows.map((row: ProfileRow) => row.payload);
|
return result.rows.map((row) => toCustomWorldLibraryEntry(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertCustomWorldProfile(
|
async upsertCustomWorldProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
profile: CustomWorldProfileRecord,
|
profile: CustomWorldProfileRecord,
|
||||||
|
authorDisplayName: string,
|
||||||
) {
|
) {
|
||||||
const payload = {
|
const payload = normalizeStoredProfile(profileId, profile);
|
||||||
...profile,
|
const metadata = extractCustomWorldLibraryMetadata(payload);
|
||||||
id: profileId,
|
const now = new Date().toISOString();
|
||||||
};
|
|
||||||
|
|
||||||
await this.db.query(
|
await this.db.query(
|
||||||
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
|
`INSERT INTO custom_world_profiles (
|
||||||
VALUES ($1, $2, $3, $4)
|
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
|
ON CONFLICT (user_id, profile_id) DO UPDATE SET
|
||||||
payload_json = EXCLUDED.payload_json,
|
payload_json = EXCLUDED.payload_json,
|
||||||
updated_at = EXCLUDED.updated_at`,
|
updated_at = EXCLUDED.updated_at,
|
||||||
[userId, profileId, payload, new Date().toISOString()],
|
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) {
|
async deleteCustomWorldProfile(userId: string, profileId: string) {
|
||||||
@@ -217,4 +419,169 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
|||||||
|
|
||||||
return this.listCustomWorldProfiles(userId);
|
return this.listCustomWorldProfiles(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<CustomWorldCardRow>(
|
||||||
|
`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<CustomWorldEntryRow>(
|
||||||
|
`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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { z } from 'zod';
|
|||||||
import type {
|
import type {
|
||||||
AnswerCustomWorldSessionQuestionRequest,
|
AnswerCustomWorldSessionQuestionRequest,
|
||||||
CreateCustomWorldSessionRequest,
|
CreateCustomWorldSessionRequest,
|
||||||
|
CustomWorldGalleryDetailResponse,
|
||||||
|
CustomWorldGalleryResponse,
|
||||||
|
CustomWorldLibraryMutationResponse,
|
||||||
|
CustomWorldLibraryResponse,
|
||||||
RuntimeSettings,
|
RuntimeSettings,
|
||||||
SavedGameSnapshotInput,
|
SavedGameSnapshotInput,
|
||||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||||
@@ -27,6 +31,8 @@ import {
|
|||||||
prepareEventStreamResponse,
|
prepareEventStreamResponse,
|
||||||
sendApiResponse,
|
sendApiResponse,
|
||||||
} from '../http.js';
|
} from '../http.js';
|
||||||
|
import { requireJwtAuth } from '../middleware/auth.js';
|
||||||
|
import { routeMeta } from '../middleware/routeMeta.js';
|
||||||
import {
|
import {
|
||||||
generateCharacterChatSuggestionsFromOrchestrator,
|
generateCharacterChatSuggestionsFromOrchestrator,
|
||||||
generateCharacterChatSummaryFromOrchestrator,
|
generateCharacterChatSummaryFromOrchestrator,
|
||||||
@@ -34,8 +40,6 @@ import {
|
|||||||
streamNpcChatDialogueFromOrchestrator,
|
streamNpcChatDialogueFromOrchestrator,
|
||||||
streamNpcRecruitDialogueFromOrchestrator,
|
streamNpcRecruitDialogueFromOrchestrator,
|
||||||
} from '../modules/ai/chatOrchestrator.js';
|
} from '../modules/ai/chatOrchestrator.js';
|
||||||
import { requireJwtAuth } from '../middleware/auth.js';
|
|
||||||
import { routeMeta } from '../middleware/routeMeta.js';
|
|
||||||
import {
|
import {
|
||||||
hydrateSavedSnapshot,
|
hydrateSavedSnapshot,
|
||||||
normalizeSavedSnapshotPayload,
|
normalizeSavedSnapshotPayload,
|
||||||
@@ -104,6 +108,15 @@ function readParam(param: string | string[] | undefined) {
|
|||||||
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
|
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) {
|
export function createRuntimeRoutes(context: AppContext) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||||
@@ -202,11 +215,55 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
'/runtime/custom-world-library',
|
'/runtime/custom-world-library',
|
||||||
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
|
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
|
||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
sendApiResponse(response, {
|
sendApiResponse(
|
||||||
profiles: await context.runtimeRepository.listCustomWorldProfiles(
|
response,
|
||||||
request.userId!,
|
{
|
||||||
),
|
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,
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -219,13 +276,19 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
throw badRequest('profileId is required');
|
throw badRequest('profileId is required');
|
||||||
}
|
}
|
||||||
const payload = customWorldProfileSchema.parse(request.body);
|
const payload = customWorldProfileSchema.parse(request.body);
|
||||||
sendApiResponse(response, {
|
const authorDisplayName = await resolveAuthDisplayName(
|
||||||
profiles: await context.runtimeRepository.upsertCustomWorldProfile(
|
context,
|
||||||
|
request.userId!,
|
||||||
|
);
|
||||||
|
sendApiResponse(
|
||||||
|
response,
|
||||||
|
await context.runtimeRepository.upsertCustomWorldProfile(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
profileId,
|
profileId,
|
||||||
jsonClone(payload.profile),
|
jsonClone(payload.profile),
|
||||||
|
authorDisplayName,
|
||||||
),
|
),
|
||||||
});
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -237,12 +300,75 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
if (!profileId) {
|
if (!profileId) {
|
||||||
throw badRequest('profileId is required');
|
throw badRequest('profileId is required');
|
||||||
}
|
}
|
||||||
sendApiResponse(response, {
|
sendApiResponse(
|
||||||
profiles: await context.runtimeRepository.deleteCustomWorldProfile(
|
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!,
|
request.userId!,
|
||||||
profileId,
|
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,
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
type CustomWorldSceneImageResult,
|
type CustomWorldSceneImageResult,
|
||||||
generateCustomWorldSceneImage,
|
generateCustomWorldSceneImage,
|
||||||
} from '../services/ai';
|
} from '../services/aiService';
|
||||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||||
import {
|
|
||||||
buildCustomWorldSceneImagePrompt,
|
|
||||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
|
||||||
} from '../services/customWorld';
|
|
||||||
import {
|
import {
|
||||||
AnimationState,
|
AnimationState,
|
||||||
CustomWorldLandmark,
|
CustomWorldLandmark,
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export function CustomWorldResultView({
|
|||||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
|
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="text-sm font-semibold text-white">保存并进入世界</span>
|
<span className="text-sm font-semibold text-white">保存到我的作品</span>
|
||||||
<span className="text-white/60">→</span>
|
<span className="text-white/60">→</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {AnimatePresence, motion} from 'motion/react';
|
|||||||
import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react';
|
import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react';
|
||||||
|
|
||||||
import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
|
import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
|
||||||
|
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||||
import {getWorldCampScenePreset} from '../data/scenePresets';
|
import {getWorldCampScenePreset} from '../data/scenePresets';
|
||||||
import {BottomTab} from '../hooks/useGameFlow';
|
import {BottomTab} from '../hooks/useGameFlow';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,7 @@ interface GameShellStoryProps {
|
|||||||
|
|
||||||
interface GameShellEntryProps {
|
interface GameShellEntryProps {
|
||||||
hasSavedGame: boolean;
|
hasSavedGame: boolean;
|
||||||
|
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||||
handleContinueGame: () => void;
|
handleContinueGame: () => void;
|
||||||
handleStartNewGame: () => void;
|
handleStartNewGame: () => void;
|
||||||
handleSaveAndExit: () => void;
|
handleSaveAndExit: () => void;
|
||||||
@@ -208,6 +210,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
|||||||
} = story;
|
} = story;
|
||||||
const {
|
const {
|
||||||
hasSavedGame,
|
hasSavedGame,
|
||||||
|
savedSnapshot,
|
||||||
handleContinueGame,
|
handleContinueGame,
|
||||||
handleStartNewGame,
|
handleStartNewGame,
|
||||||
handleSaveAndExit,
|
handleSaveAndExit,
|
||||||
@@ -272,7 +275,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
|||||||
!gameState.playerCharacter;
|
!gameState.playerCharacter;
|
||||||
const hideSelectionHero =
|
const hideSelectionHero =
|
||||||
gameState.currentScene === 'Selection' &&
|
gameState.currentScene === 'Selection' &&
|
||||||
selectionStage !== 'start';
|
selectionStage !== 'platform';
|
||||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||||
|
|
||||||
const dialogueIndicator = useMemo(() => {
|
const dialogueIndicator = useMemo(() => {
|
||||||
@@ -428,6 +431,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
|||||||
setSelectionStage={setSelectionStage}
|
setSelectionStage={setSelectionStage}
|
||||||
gameState={gameState}
|
gameState={gameState}
|
||||||
hasSavedGame={hasSavedGame}
|
hasSavedGame={hasSavedGame}
|
||||||
|
savedSnapshot={savedSnapshot}
|
||||||
handleContinueGame={handleContinueGame}
|
handleContinueGame={handleContinueGame}
|
||||||
handleStartNewGame={handleStartNewGame}
|
handleStartNewGame={handleStartNewGame}
|
||||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||||
@@ -447,7 +451,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
|||||||
customWorldProfile={gameState.customWorldProfile}
|
customWorldProfile={gameState.customWorldProfile}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
handleBackToWorldSelect();
|
handleBackToWorldSelect();
|
||||||
setSelectionStage('world');
|
setSelectionStage('platform');
|
||||||
}}
|
}}
|
||||||
onConfirm={handleCharacterSelect}
|
onConfirm={handleCharacterSelect}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ReactNode, useEffect, useState } from 'react';
|
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AUTH_STATE_EVENT,
|
AUTH_STATE_EVENT,
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
startWechatLogin,
|
startWechatLogin,
|
||||||
} from '../../services/authService';
|
} from '../../services/authService';
|
||||||
import { AccountModal } from './AccountModal';
|
import { AccountModal } from './AccountModal';
|
||||||
|
import { AuthUiContext } from './AuthUiContext';
|
||||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||||
import { LoginScreen } from './LoginScreen';
|
import { LoginScreen } from './LoginScreen';
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
const [bindingPhone, setBindingPhone] = useState(false);
|
const [bindingPhone, setBindingPhone] = useState(false);
|
||||||
const [wechatLoading, setWechatLoading] = useState(false);
|
const [wechatLoading, setWechatLoading] = useState(false);
|
||||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||||
|
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
|
||||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||||
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
||||||
@@ -304,6 +306,19 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
};
|
};
|
||||||
}, [showAccountModal, status]);
|
}, [showAccountModal, status]);
|
||||||
|
|
||||||
|
const authUiValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
openAccountModal: () => setShowAccountModal(true),
|
||||||
|
logout: async () => {
|
||||||
|
await logoutAuthUser();
|
||||||
|
setShowAccountModal(false);
|
||||||
|
},
|
||||||
|
setGlobalAccountActionsVisible: setShowGlobalAccountActions,
|
||||||
|
}),
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
if (status === 'checking') {
|
if (status === 'checking') {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||||
@@ -468,140 +483,144 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<AuthUiContext.Provider value={authUiValue}>
|
||||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
<div className="relative">
|
||||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
{showGlobalAccountActions ? (
|
||||||
<button
|
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||||
type="button"
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
||||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
|
<button
|
||||||
onClick={() => setShowAccountModal(true)}
|
type="button"
|
||||||
>
|
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
|
||||||
{user.displayName}
|
onClick={() => setShowAccountModal(true)}
|
||||||
</button>
|
>
|
||||||
<button
|
{user.displayName}
|
||||||
type="button"
|
</button>
|
||||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
void logoutAuthUser();
|
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
void logoutAuthUser();
|
||||||
退出
|
}}
|
||||||
</button>
|
>
|
||||||
</div>
|
退出
|
||||||
</div>
|
</button>
|
||||||
<AccountModal
|
</div>
|
||||||
user={user}
|
</div>
|
||||||
isOpen={showAccountModal}
|
) : null}
|
||||||
riskBlocks={riskBlocks}
|
<AccountModal
|
||||||
sessions={sessions}
|
user={user}
|
||||||
auditLogs={auditLogs}
|
isOpen={showAccountModal}
|
||||||
loadingRiskBlocks={loadingRiskBlocks}
|
riskBlocks={riskBlocks}
|
||||||
loadingSessions={loadingSessions}
|
sessions={sessions}
|
||||||
loadingAuditLogs={loadingAuditLogs}
|
auditLogs={auditLogs}
|
||||||
onClose={() => setShowAccountModal(false)}
|
loadingRiskBlocks={loadingRiskBlocks}
|
||||||
onLogout={async () => {
|
loadingSessions={loadingSessions}
|
||||||
await logoutAuthUser();
|
loadingAuditLogs={loadingAuditLogs}
|
||||||
setShowAccountModal(false);
|
onClose={() => setShowAccountModal(false)}
|
||||||
}}
|
onLogout={async () => {
|
||||||
onRefreshRiskBlocks={async () => {
|
await logoutAuthUser();
|
||||||
setLoadingRiskBlocks(true);
|
setShowAccountModal(false);
|
||||||
try {
|
}}
|
||||||
setRiskBlocks(await getAuthRiskBlocks());
|
onRefreshRiskBlocks={async () => {
|
||||||
} catch (blockError) {
|
setLoadingRiskBlocks(true);
|
||||||
setError(
|
try {
|
||||||
blockError instanceof Error
|
setRiskBlocks(await getAuthRiskBlocks());
|
||||||
? blockError.message
|
} catch (blockError) {
|
||||||
: '读取安全状态失败,请稍后再试。',
|
setError(
|
||||||
);
|
blockError instanceof Error
|
||||||
} finally {
|
? blockError.message
|
||||||
setLoadingRiskBlocks(false);
|
: '读取安全状态失败,请稍后再试。',
|
||||||
}
|
);
|
||||||
}}
|
} finally {
|
||||||
onLiftRiskBlock={async (scopeType) => {
|
setLoadingRiskBlocks(false);
|
||||||
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;
|
}}
|
||||||
}
|
onLiftRiskBlock={async (scopeType) => {
|
||||||
}}
|
try {
|
||||||
onChangePhone={async (phone, code) => {
|
await liftAuthRiskBlock(scopeType);
|
||||||
const nextUser = await changePhoneNumber(phone, code);
|
setRiskBlocks(await getAuthRiskBlocks());
|
||||||
setChangePhoneCaptchaChallenge(null);
|
setAuditLogs(await getAuthAuditLogs());
|
||||||
setUser(nextUser);
|
} catch (liftError) {
|
||||||
}}
|
setError(
|
||||||
/>
|
liftError instanceof Error
|
||||||
{children}
|
? liftError.message
|
||||||
</div>
|
: '解除保护失败,请稍后再试。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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}
|
||||||
|
</div>
|
||||||
|
</AuthUiContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/components/auth/AuthUiContext.ts
Normal file
16
src/components/auth/AuthUiContext.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
import type { AuthUser } from '../../services/authService';
|
||||||
|
|
||||||
|
type AuthUiContextValue = {
|
||||||
|
user: AuthUser | null;
|
||||||
|
openAccountModal: () => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
setGlobalAccountActionsVisible: (visible: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthUiContext = createContext<AuthUiContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useAuthUi() {
|
||||||
|
return useContext(AuthUiContext);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
|
||||||
import type { BottomTab } from '../../hooks/useGameFlow';
|
import type { BottomTab } from '../../hooks/useGameFlow';
|
||||||
import type {
|
import type {
|
||||||
@@ -8,6 +9,7 @@ import type {
|
|||||||
InventoryFlowUi,
|
InventoryFlowUi,
|
||||||
QuestFlowUi,
|
QuestFlowUi,
|
||||||
} from '../../hooks/useStoryGeneration';
|
} from '../../hooks/useStoryGeneration';
|
||||||
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import type {
|
import type {
|
||||||
CompanionRenderState,
|
CompanionRenderState,
|
||||||
CustomWorldProfile,
|
CustomWorldProfile,
|
||||||
@@ -17,11 +19,38 @@ import type {
|
|||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { UI_CHROME } from '../../uiAssets';
|
import { UI_CHROME } from '../../uiAssets';
|
||||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||||
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
|
import type { SelectionStage } from './PreGameSelectionFlow';
|
||||||
import { GameShellStoryPanels } from './GameShellStoryPanels';
|
|
||||||
import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow';
|
|
||||||
import type { GameShellAdventureStatistics } from './types';
|
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 (
|
||||||
|
<div className="flex h-full min-h-0 items-center justify-center">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GameShellMainContent({
|
export function GameShellMainContent({
|
||||||
gameState,
|
gameState,
|
||||||
visibleGameState,
|
visibleGameState,
|
||||||
@@ -34,6 +63,7 @@ export function GameShellMainContent({
|
|||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
isCharacterSelectionStage,
|
isCharacterSelectionStage,
|
||||||
hasSavedGame,
|
hasSavedGame,
|
||||||
|
savedSnapshot,
|
||||||
handleContinueGame,
|
handleContinueGame,
|
||||||
handleStartNewGame,
|
handleStartNewGame,
|
||||||
handleCustomWorldSelect,
|
handleCustomWorldSelect,
|
||||||
@@ -71,6 +101,7 @@ export function GameShellMainContent({
|
|||||||
setSelectionStage: (stage: SelectionStage) => void;
|
setSelectionStage: (stage: SelectionStage) => void;
|
||||||
isCharacterSelectionStage: boolean;
|
isCharacterSelectionStage: boolean;
|
||||||
hasSavedGame: boolean;
|
hasSavedGame: boolean;
|
||||||
|
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||||
handleContinueGame: () => void;
|
handleContinueGame: () => void;
|
||||||
handleStartNewGame: () => void;
|
handleStartNewGame: () => void;
|
||||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||||
@@ -110,15 +141,20 @@ export function GameShellMainContent({
|
|||||||
>
|
>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{!gameState.worldType && (
|
{!gameState.worldType && (
|
||||||
<PreGameSelectionFlow
|
<Suspense
|
||||||
selectionStage={selectionStage}
|
fallback={<MainContentLoadingFallback label="正在加载平台首页..." />}
|
||||||
setSelectionStage={setSelectionStage}
|
>
|
||||||
gameState={gameState}
|
<PreGameSelectionFlow
|
||||||
hasSavedGame={hasSavedGame}
|
selectionStage={selectionStage}
|
||||||
handleContinueGame={handleContinueGame}
|
setSelectionStage={setSelectionStage}
|
||||||
handleStartNewGame={handleStartNewGame}
|
gameState={gameState}
|
||||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
hasSavedGame={hasSavedGame}
|
||||||
/>
|
savedSnapshot={savedSnapshot}
|
||||||
|
handleContinueGame={handleContinueGame}
|
||||||
|
handleStartNewGame={handleStartNewGame}
|
||||||
|
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gameState.worldType && !gameState.playerCharacter && (
|
{gameState.worldType && !gameState.playerCharacter && (
|
||||||
@@ -129,50 +165,58 @@ export function GameShellMainContent({
|
|||||||
exit={{ opacity: 0, y: -12 }}
|
exit={{ opacity: 0, y: -12 }}
|
||||||
className="flex h-full min-h-0 flex-col"
|
className="flex h-full min-h-0 flex-col"
|
||||||
>
|
>
|
||||||
<CharacterSelectionFlow
|
<Suspense
|
||||||
worldType={gameState.worldType}
|
fallback={<MainContentLoadingFallback label="正在加载角色选择..." />}
|
||||||
customWorldProfile={gameState.customWorldProfile}
|
>
|
||||||
onBack={() => {
|
<CharacterSelectionFlow
|
||||||
handleBackToWorldSelect();
|
worldType={gameState.worldType}
|
||||||
setSelectionStage('world');
|
customWorldProfile={gameState.customWorldProfile}
|
||||||
}}
|
onBack={() => {
|
||||||
onConfirm={handleCharacterSelect}
|
handleBackToWorldSelect();
|
||||||
/>
|
setSelectionStage('platform');
|
||||||
|
}}
|
||||||
|
onConfirm={handleCharacterSelect}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleGameState.playerCharacter && visibleCurrentStory && (
|
{visibleGameState.playerCharacter && visibleCurrentStory && (
|
||||||
<motion.div key="story-flow" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex h-full min-h-0 flex-col">
|
<motion.div key="story-flow" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex h-full min-h-0 flex-col">
|
||||||
<GameShellStoryPanels
|
<Suspense
|
||||||
visibleGameState={visibleGameState}
|
fallback={<MainContentLoadingFallback label="正在加载冒险面板..." />}
|
||||||
visibleCurrentStory={visibleCurrentStory}
|
>
|
||||||
isLoading={isLoading}
|
<GameShellStoryPanels
|
||||||
aiError={aiError}
|
visibleGameState={visibleGameState}
|
||||||
bottomTab={bottomTab}
|
visibleCurrentStory={visibleCurrentStory}
|
||||||
setBottomTab={setBottomTab}
|
isLoading={isLoading}
|
||||||
displayedOptions={displayedOptions}
|
aiError={aiError}
|
||||||
hideStoryOptions={hideStoryOptions}
|
bottomTab={bottomTab}
|
||||||
canRefreshOptions={canRefreshOptions}
|
setBottomTab={setBottomTab}
|
||||||
handleRefreshOptions={handleRefreshOptions}
|
displayedOptions={displayedOptions}
|
||||||
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
hideStoryOptions={hideStoryOptions}
|
||||||
characterChatUi={characterChatUi}
|
canRefreshOptions={canRefreshOptions}
|
||||||
inventoryUi={inventoryUi}
|
handleRefreshOptions={handleRefreshOptions}
|
||||||
battleRewardUi={battleRewardUi}
|
handleSceneTransitionChoice={handleSceneTransitionChoice}
|
||||||
questUi={questUi}
|
characterChatUi={characterChatUi}
|
||||||
goalUi={goalUi}
|
inventoryUi={inventoryUi}
|
||||||
companionRenderStates={companionRenderStates}
|
battleRewardUi={battleRewardUi}
|
||||||
characterChatSummaries={characterChatSummaries}
|
questUi={questUi}
|
||||||
openOverlayPanel={openOverlayPanel}
|
goalUi={goalUi}
|
||||||
openCampModal={openCampModal}
|
companionRenderStates={companionRenderStates}
|
||||||
openPartyMemberDetails={openPartyMemberDetails}
|
characterChatSummaries={characterChatSummaries}
|
||||||
adventureStatistics={adventureStatistics}
|
openOverlayPanel={openOverlayPanel}
|
||||||
musicVolume={musicVolume}
|
openCampModal={openCampModal}
|
||||||
onMusicVolumeChange={onMusicVolumeChange}
|
openPartyMemberDetails={openPartyMemberDetails}
|
||||||
onSaveAndExit={() => {
|
adventureStatistics={adventureStatistics}
|
||||||
resetForSaveAndExit();
|
musicVolume={musicVolume}
|
||||||
handleSaveAndExit();
|
onMusicVolumeChange={onMusicVolumeChange}
|
||||||
}}
|
onSaveAndExit={() => {
|
||||||
/>
|
resetForSaveAndExit();
|
||||||
|
handleSaveAndExit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
|
import { lazy, Suspense, useEffect } from 'react';
|
||||||
|
|
||||||
import { UI_CHROME } from '../../uiAssets';
|
import { UI_CHROME } from '../../uiAssets';
|
||||||
import { GameShellCanvasStage } from './GameShellCanvasStage';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { GameShellMainContent } from './GameShellMainContent';
|
import { GameShellMainContent } from './GameShellMainContent';
|
||||||
import { GameShellOverlays } from './GameShellOverlays';
|
|
||||||
import type { GameShellProps } from './types';
|
import type { GameShellProps } from './types';
|
||||||
import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel';
|
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) {
|
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
|
||||||
|
const authUi = useAuthUi();
|
||||||
const {
|
const {
|
||||||
gameState,
|
gameState,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -29,6 +44,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
|||||||
} = story;
|
} = story;
|
||||||
const {
|
const {
|
||||||
hasSavedGame,
|
hasSavedGame,
|
||||||
|
savedSnapshot,
|
||||||
handleContinueGame,
|
handleContinueGame,
|
||||||
handleStartNewGame,
|
handleStartNewGame,
|
||||||
handleSaveAndExit,
|
handleSaveAndExit,
|
||||||
@@ -80,6 +96,14 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
|||||||
companions,
|
companions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authUi?.setGlobalAccountActionsVisible(Boolean(gameState.playerCharacter));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
authUi?.setGlobalAccountActionsVisible(true);
|
||||||
|
};
|
||||||
|
}, [authUi, gameState.playerCharacter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
|
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
|
||||||
@@ -89,18 +113,20 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
|||||||
backgroundRepeat: 'repeat',
|
backgroundRepeat: 'repeat',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GameShellCanvasStage
|
<Suspense fallback={null}>
|
||||||
gameState={gameState}
|
<GameShellCanvasStage
|
||||||
visibleGameState={visibleGameState}
|
gameState={gameState}
|
||||||
hideSelectionHero={hideSelectionHero}
|
visibleGameState={visibleGameState}
|
||||||
canvasCompanionRenderStates={canvasCompanionRenderStates}
|
hideSelectionHero={hideSelectionHero}
|
||||||
dialogueIndicator={dialogueIndicator}
|
canvasCompanionRenderStates={canvasCompanionRenderStates}
|
||||||
sceneTransitionPhase={sceneTransitionPhase}
|
dialogueIndicator={dialogueIndicator}
|
||||||
sceneTransitionToken={sceneTransitionToken}
|
sceneTransitionPhase={sceneTransitionPhase}
|
||||||
setSelectedSceneEntity={setSelectedSceneEntity}
|
sceneTransitionToken={sceneTransitionToken}
|
||||||
setIsMapOpen={setIsMapOpen}
|
setSelectedSceneEntity={setSelectedSceneEntity}
|
||||||
setSceneTransitionDurations={setSceneTransitionDurations}
|
setIsMapOpen={setIsMapOpen}
|
||||||
/>
|
setSceneTransitionDurations={setSceneTransitionDurations}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<GameShellMainContent
|
<GameShellMainContent
|
||||||
gameState={gameState}
|
gameState={gameState}
|
||||||
@@ -114,6 +140,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
|||||||
setSelectionStage={setSelectionStage}
|
setSelectionStage={setSelectionStage}
|
||||||
isCharacterSelectionStage={isCharacterSelectionStage}
|
isCharacterSelectionStage={isCharacterSelectionStage}
|
||||||
hasSavedGame={hasSavedGame}
|
hasSavedGame={hasSavedGame}
|
||||||
|
savedSnapshot={savedSnapshot}
|
||||||
handleContinueGame={handleContinueGame}
|
handleContinueGame={handleContinueGame}
|
||||||
handleStartNewGame={handleStartNewGame}
|
handleStartNewGame={handleStartNewGame}
|
||||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||||
@@ -141,34 +168,35 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
|||||||
handleSaveAndExit={handleSaveAndExit}
|
handleSaveAndExit={handleSaveAndExit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GameShellOverlays
|
<Suspense fallback={null}>
|
||||||
gameState={gameState}
|
<GameShellOverlays
|
||||||
isLoading={isLoading}
|
gameState={gameState}
|
||||||
isMapOpen={isMapOpen}
|
isLoading={isLoading}
|
||||||
setIsMapOpen={setIsMapOpen}
|
isMapOpen={isMapOpen}
|
||||||
npcUi={npcUi}
|
setIsMapOpen={setIsMapOpen}
|
||||||
characterChatUi={characterChatUi}
|
npcUi={npcUi}
|
||||||
inventoryUi={inventoryUi}
|
characterChatUi={characterChatUi}
|
||||||
companionRenderStates={companionRenderStates}
|
inventoryUi={inventoryUi}
|
||||||
characterChatSummaries={characterChatSummaries}
|
companionRenderStates={companionRenderStates}
|
||||||
overlayPanel={overlayPanel}
|
characterChatSummaries={characterChatSummaries}
|
||||||
closeOverlayPanel={closeOverlayPanel}
|
overlayPanel={overlayPanel}
|
||||||
openCampModal={openCampModal}
|
closeOverlayPanel={closeOverlayPanel}
|
||||||
openPartyMemberDetails={openPartyMemberDetails}
|
openCampModal={openCampModal}
|
||||||
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
|
openPartyMemberDetails={openPartyMemberDetails}
|
||||||
selectedSceneEntity={selectedSceneEntity}
|
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
|
||||||
closeAdventureEntityModal={closeAdventureEntityModal}
|
selectedSceneEntity={selectedSceneEntity}
|
||||||
shouldMountCampModal={shouldMountCampModal}
|
closeAdventureEntityModal={closeAdventureEntityModal}
|
||||||
showTeamModal={showTeamModal}
|
shouldMountCampModal={shouldMountCampModal}
|
||||||
closeCampModal={closeCampModal}
|
showTeamModal={showTeamModal}
|
||||||
onBenchCompanion={onBenchCompanion}
|
closeCampModal={closeCampModal}
|
||||||
onActivateRosterCompanion={onActivateRosterCompanion}
|
onBenchCompanion={onBenchCompanion}
|
||||||
shouldMountMapModal={shouldMountMapModal}
|
onActivateRosterCompanion={onActivateRosterCompanion}
|
||||||
handleMapTravelToScene={handleMapTravelToScene}
|
shouldMountMapModal={shouldMountMapModal}
|
||||||
shouldMountCharacterChatModal={shouldMountCharacterChatModal}
|
handleMapTravelToScene={handleMapTravelToScene}
|
||||||
shouldMountNpcModals={shouldMountNpcModals}
|
shouldMountCharacterChatModal={shouldMountCharacterChatModal}
|
||||||
/>
|
shouldMountNpcModals={shouldMountNpcModals}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
356
src/components/game-shell/PlatformHomeView.tsx
Normal file
356
src/components/game-shell/PlatformHomeView.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="mb-3 flex items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||||
|
{detail}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-base font-bold text-white">{title}</div>
|
||||||
|
</div>
|
||||||
|
{actionLabel && onAction ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAction}
|
||||||
|
className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyShelf({
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pixel-nine-slice pixel-panel rounded-[1.35rem] text-sm leading-6 text-zinc-300"
|
||||||
|
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="pixel-nine-slice pixel-pressable relative flex h-[15rem] w-[15.25rem] shrink-0 flex-col overflow-hidden text-left"
|
||||||
|
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 14 })}
|
||||||
|
>
|
||||||
|
{coverImage ? (
|
||||||
|
<img
|
||||||
|
src={coverImage}
|
||||||
|
alt={entry.worldName}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover opacity-40"
|
||||||
|
style={{ imageRendering: 'pixelated' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{leadPortrait ? (
|
||||||
|
<img
|
||||||
|
src={leadPortrait}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
|
||||||
|
style={{ imageRendering: 'pixelated' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.9))]" />
|
||||||
|
<div className="relative z-10 flex h-full flex-col">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-100">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||||
|
{metaLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto">
|
||||||
|
<div className="line-clamp-1 text-xl font-black text-white">
|
||||||
|
{entry.worldName}
|
||||||
|
</div>
|
||||||
|
{entry.subtitle ? (
|
||||||
|
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-zinc-300/85">
|
||||||
|
{entry.subtitle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-2 line-clamp-2 text-xs leading-5 text-zinc-200/90">
|
||||||
|
{entry.summaryText || '等待补充世界摘要。'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||||
|
{describePlatformThemeLabel(entry.themeMode)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CustomWorldProfile>[];
|
||||||
|
isLoadingPlatform: boolean;
|
||||||
|
platformError: string | null;
|
||||||
|
onContinueGame: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onOpenCreateWorld: () => void;
|
||||||
|
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||||
|
onOpenLibraryDetail: (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => 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 (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-amber-300/20 bg-amber-500/10">
|
||||||
|
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||||
|
GENARRATIVE PLATFORM
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-lg font-black text-white">
|
||||||
|
自定义世界广场
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
{authUi?.user ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => authUi.openAccountModal()}
|
||||||
|
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-100 transition hover:border-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
{authUi.user.displayName}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||||
|
<div className="space-y-4 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||||
|
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
|
||||||
|
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(250,204,21,0.16),transparent_36%),linear-gradient(135deg,rgba(15,23,42,0.78),rgba(8,10,14,0.95))]" />
|
||||||
|
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-amber-100">
|
||||||
|
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
|
||||||
|
</span>
|
||||||
|
<div className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] text-zinc-100">
|
||||||
|
{hasSavedGame ? '继续冒险' : '创建世界'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-black text-white">
|
||||||
|
{hasSavedGame ? snapshotWorldName : '把第一页变成你的作品页'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||||
|
{hasSavedGame
|
||||||
|
? `${snapshotCharacterName} 的上一次冒险已保存在云端,点这里直接回到故事现场。`
|
||||||
|
: '从设定、角色到场景网络,一次生成一部可游玩的自定义 RPG,再决定是否发布到广场。'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{platformError ? (
|
||||||
|
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||||
|
{platformError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader
|
||||||
|
title="精选推荐"
|
||||||
|
detail="为你挑选"
|
||||||
|
actionLabel="看看最新"
|
||||||
|
onAction={onRefresh}
|
||||||
|
/>
|
||||||
|
{isLoadingPlatform ? (
|
||||||
|
<EmptyShelf text="正在读取精选作品..." />
|
||||||
|
) : featuredShelf.length > 0 ? (
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||||
|
{featuredShelf.map((entry) => (
|
||||||
|
<WorldCard
|
||||||
|
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
|
||||||
|
entry={entry}
|
||||||
|
badge="推荐"
|
||||||
|
metaLabel={describePlatformThemeLabel(entry.themeMode)}
|
||||||
|
onClick={() => onOpenGalleryDetail(entry)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="最新发布" detail="玩家广场" />
|
||||||
|
{isLoadingPlatform ? (
|
||||||
|
<EmptyShelf text="正在读取最新发布..." />
|
||||||
|
) : latestEntries.length > 0 ? (
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||||
|
{latestEntries.map((entry) => (
|
||||||
|
<WorldCard
|
||||||
|
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
|
||||||
|
entry={entry}
|
||||||
|
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||||
|
metaLabel={entry.authorDisplayName}
|
||||||
|
onClick={() => onOpenGalleryDetail(entry)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="我的作品" detail="草稿与已发布" />
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenCreateWorld}
|
||||||
|
className="pixel-nine-slice pixel-pressable relative min-h-[13rem] overflow-hidden text-left"
|
||||||
|
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.92))]" />
|
||||||
|
<div className="relative z-10 flex h-full flex-col">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/20 bg-sky-500/10">
|
||||||
|
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto">
|
||||||
|
<div className="text-2xl font-black text-white">
|
||||||
|
创建新世界
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||||
|
新建一个只属于你的世界设定,生成后先进入草稿库,再决定要不要发布。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{myEntries.map((entry) => (
|
||||||
|
<WorldCard
|
||||||
|
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||||
|
entry={entry}
|
||||||
|
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||||
|
metaLabel={entry.visibility === 'published' ? formatPlatformWorldTime(entry.publishedAt) : '仅自己可见'}
|
||||||
|
onClick={() => onOpenLibraryDetail(entry)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!isLoadingPlatform && myEntries.length === 0 ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<EmptyShelf text="你还没有保存任何自定义世界,先创建一个草稿开始吧。" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
278
src/components/game-shell/PlatformWorldDetailView.tsx
Normal file
278
src/components/game-shell/PlatformWorldDetailView.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`rounded-full border px-4 py-2 text-sm transition ${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlatformWorldDetailView({
|
||||||
|
entry,
|
||||||
|
isMutating,
|
||||||
|
error,
|
||||||
|
onBack,
|
||||||
|
onStartGame,
|
||||||
|
onContinueEdit,
|
||||||
|
onPublish,
|
||||||
|
onUnpublish,
|
||||||
|
}: {
|
||||||
|
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
返回广场
|
||||||
|
</button>
|
||||||
|
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300">
|
||||||
|
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||||
|
<div className="space-y-4 pb-2">
|
||||||
|
<div
|
||||||
|
className="pixel-nine-slice relative overflow-hidden"
|
||||||
|
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||||
|
>
|
||||||
|
{coverImage ? (
|
||||||
|
<img
|
||||||
|
src={coverImage}
|
||||||
|
alt={entry.worldName}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||||||
|
style={{ imageRendering: 'pixelated' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{leadPortrait ? (
|
||||||
|
<img
|
||||||
|
src={leadPortrait}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
|
||||||
|
style={{ imageRendering: 'pixelated' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.9))]" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-100">
|
||||||
|
{describePlatformThemeLabel(entry.themeMode)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
|
||||||
|
{entry.authorDisplayName}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
|
||||||
|
{entry.visibility === 'published'
|
||||||
|
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||||
|
: '仅自己可见'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-3xl font-black text-white">
|
||||||
|
{entry.worldName}
|
||||||
|
</div>
|
||||||
|
{entry.subtitle ? (
|
||||||
|
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-300/88">
|
||||||
|
{entry.subtitle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-4 max-w-[36rem] text-sm leading-7 text-zinc-200/88">
|
||||||
|
{entry.summaryText || '等待补充世界摘要。'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<div
|
||||||
|
className="pixel-nine-slice"
|
||||||
|
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||||
|
世界信息
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||||
|
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||||
|
可玩角色
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-bold">{entry.playableNpcCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||||
|
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||||
|
地标
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-bold">{entry.landmarkCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||||
|
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||||
|
阵营
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-bold">
|
||||||
|
{entry.profile.majorFactions.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
|
||||||
|
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||||
|
冲突
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-bold">
|
||||||
|
{entry.profile.coreConflicts.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||||
|
关键角色
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||||
|
{previewCharacters.map((character) => (
|
||||||
|
<div
|
||||||
|
key={character.id}
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||||
|
{character.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
|
||||||
|
{character.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||||
|
关键场景
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||||
|
{previewLandmarks.map((landmark) => (
|
||||||
|
<div
|
||||||
|
key={landmark.id}
|
||||||
|
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||||
|
{landmark.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
|
||||||
|
{landmark.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="pixel-nine-slice"
|
||||||
|
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
|
||||||
|
操作
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
|
<ActionButton
|
||||||
|
label="开始游戏"
|
||||||
|
onClick={onStartGame}
|
||||||
|
tone="primary"
|
||||||
|
/>
|
||||||
|
{onContinueEdit ? (
|
||||||
|
<ActionButton
|
||||||
|
label="继续创作"
|
||||||
|
onClick={onContinueEdit}
|
||||||
|
disabled={isMutating}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{onPublish ? (
|
||||||
|
<ActionButton
|
||||||
|
label="发布到广场"
|
||||||
|
onClick={onPublish}
|
||||||
|
tone="primary"
|
||||||
|
disabled={isMutating}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{onUnpublish ? (
|
||||||
|
<ActionButton
|
||||||
|
label="下架作品"
|
||||||
|
onClick={onUnpublish}
|
||||||
|
tone="danger"
|
||||||
|
disabled={isMutating}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
90
src/components/game-shell/platformWorldPresentation.ts
Normal file
90
src/components/game-shell/platformWorldPresentation.ts
Normal file
@@ -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<CustomWorldProfile>;
|
||||||
|
|
||||||
|
export function isLibraryWorldEntry(
|
||||||
|
entry: PlatformWorldCardLike,
|
||||||
|
): entry is CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||||
|
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 '回响';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,11 @@ import type {
|
|||||||
QuestFlowUi,
|
QuestFlowUi,
|
||||||
StoryGenerationNpcUi,
|
StoryGenerationNpcUi,
|
||||||
} from '../../hooks/useStoryGeneration';
|
} from '../../hooks/useStoryGeneration';
|
||||||
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import type {
|
import type {
|
||||||
Character,
|
Character,
|
||||||
CustomWorldProfile,
|
|
||||||
CompanionRenderState,
|
CompanionRenderState,
|
||||||
|
CustomWorldProfile,
|
||||||
GameState,
|
GameState,
|
||||||
StoryMoment,
|
StoryMoment,
|
||||||
StoryOption,
|
StoryOption,
|
||||||
@@ -43,6 +44,7 @@ export interface GameShellStoryProps {
|
|||||||
|
|
||||||
export interface GameShellEntryProps {
|
export interface GameShellEntryProps {
|
||||||
hasSavedGame: boolean;
|
hasSavedGame: boolean;
|
||||||
|
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||||
handleContinueGame: () => void;
|
handleContinueGame: () => void;
|
||||||
handleStartNewGame: () => void;
|
handleStartNewGame: () => void;
|
||||||
handleSaveAndExit: () => void;
|
handleSaveAndExit: () => void;
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function useGameShellRuntimeViewModel(params: Pick<
|
|||||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||||
const hideSelectionHero =
|
const hideSelectionHero =
|
||||||
gameState.currentScene === 'Selection' &&
|
gameState.currentScene === 'Selection' &&
|
||||||
shellViewModel.selectionStage !== 'start';
|
shellViewModel.selectionStage !== 'platform';
|
||||||
|
|
||||||
const dialogueIndicator = useMemo(
|
const dialogueIndicator = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function useGameShellViewModel(params: {
|
|||||||
characterChatModalOpen,
|
characterChatModalOpen,
|
||||||
hasNpcModalOpen,
|
hasNpcModalOpen,
|
||||||
} = params;
|
} = params;
|
||||||
const [selectionStage, setSelectionStage] = useState<SelectionStage>('start');
|
const [selectionStage, setSelectionStage] = useState<SelectionStage>('platform');
|
||||||
const [overlayPanel, setOverlayPanel] = useState<OverlayPanel>(null);
|
const [overlayPanel, setOverlayPanel] = useState<OverlayPanel>(null);
|
||||||
const [selectedSceneEntity, setSelectedSceneEntity] = useState<GameCanvasEntitySelection | null>(null);
|
const [selectedSceneEntity, setSelectedSceneEntity] = useState<GameCanvasEntitySelection | null>(null);
|
||||||
const [showTeamModal, setShowTeamModal] = useState(false);
|
const [showTeamModal, setShowTeamModal] = useState(false);
|
||||||
@@ -56,13 +56,13 @@ export function useGameShellViewModel(params: {
|
|||||||
const openCampModal = () => setShowTeamModal(true);
|
const openCampModal = () => setShowTeamModal(true);
|
||||||
const closeCampModal = () => setShowTeamModal(false);
|
const closeCampModal = () => setShowTeamModal(false);
|
||||||
|
|
||||||
const resetSelectionFlow = () => setSelectionStage('start');
|
const resetSelectionFlow = () => setSelectionStage('platform');
|
||||||
|
|
||||||
const resetForSaveAndExit = () => {
|
const resetForSaveAndExit = () => {
|
||||||
setSelectedSceneEntity(null);
|
setSelectedSceneEntity(null);
|
||||||
setOverlayPanel(null);
|
setOverlayPanel(null);
|
||||||
setShowTeamModal(false);
|
setShowTeamModal(false);
|
||||||
setSelectionStage('start');
|
setSelectionStage('platform');
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
putSaveSnapshot,
|
putSaveSnapshot,
|
||||||
} from '../services/storageService';
|
} from '../services/storageService';
|
||||||
import type { GameState, StoryMoment } from '../types';
|
import type { GameState, StoryMoment } from '../types';
|
||||||
import type { BottomTab } from './useGameFlow';
|
|
||||||
import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator';
|
import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator';
|
||||||
|
import type { BottomTab } from './useGameFlow';
|
||||||
|
|
||||||
const AUTO_SAVE_DELAY_MS = 400;
|
const AUTO_SAVE_DELAY_MS = 400;
|
||||||
|
|
||||||
@@ -296,6 +296,7 @@ export function useGamePersistence({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hasSavedGame,
|
hasSavedGame,
|
||||||
|
savedSnapshot,
|
||||||
isHydratingSnapshot,
|
isHydratingSnapshot,
|
||||||
isPersistingSnapshot,
|
isPersistingSnapshot,
|
||||||
persistenceError,
|
persistenceError,
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export function useGameShellRuntime(): GameShellProps {
|
|||||||
},
|
},
|
||||||
entry: {
|
entry: {
|
||||||
hasSavedGame: persistence.hasSavedGame,
|
hasSavedGame: persistence.hasSavedGame,
|
||||||
|
savedSnapshot: persistence.savedSnapshot,
|
||||||
handleContinueGame,
|
handleContinueGame,
|
||||||
handleStartNewGame,
|
handleStartNewGame,
|
||||||
handleSaveAndExit,
|
handleSaveAndExit,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
BasicOkResult,
|
BasicOkResult,
|
||||||
|
CustomWorldGalleryDetailResponse,
|
||||||
|
CustomWorldGalleryResponse,
|
||||||
|
CustomWorldLibraryEntry,
|
||||||
|
CustomWorldLibraryMutationResponse,
|
||||||
CustomWorldLibraryResponse,
|
CustomWorldLibraryResponse,
|
||||||
RuntimeSettings,
|
RuntimeSettings,
|
||||||
} from '../../packages/shared/src/contracts/runtime';
|
} from '../../packages/shared/src/contracts/runtime';
|
||||||
@@ -117,14 +121,14 @@ export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
return Array.isArray(response?.entries) ? response.entries : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertCustomWorldProfile(
|
export async function upsertCustomWorldProfile(
|
||||||
profile: CustomWorldProfile,
|
profile: CustomWorldProfile,
|
||||||
options: RuntimeRequestOptions = {},
|
options: RuntimeRequestOptions = {},
|
||||||
) {
|
) {
|
||||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
const response = await requestRuntimeJson<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
|
||||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -137,7 +141,10 @@ export async function upsertCustomWorldProfile(
|
|||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
return {
|
||||||
|
entry: response.entry,
|
||||||
|
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCustomWorldProfile(
|
export async function deleteCustomWorldProfile(
|
||||||
@@ -151,7 +158,67 @@ export async function deleteCustomWorldProfile(
|
|||||||
options,
|
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<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
|
||||||
|
`/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<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
|
||||||
|
`/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<CustomWorldGalleryResponse>(
|
||||||
|
'/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<CustomWorldGalleryDetailResponse<CustomWorldProfile>>(
|
||||||
|
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||||
|
{ method: 'GET' },
|
||||||
|
'读取作品详情失败',
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtimeStorageClient = {
|
export const runtimeStorageClient = {
|
||||||
@@ -163,4 +230,10 @@ export const runtimeStorageClient = {
|
|||||||
listCustomWorldLibrary,
|
listCustomWorldLibrary,
|
||||||
upsertCustomWorldProfile,
|
upsertCustomWorldProfile,
|
||||||
deleteCustomWorldProfile,
|
deleteCustomWorldProfile,
|
||||||
|
publishCustomWorldProfile,
|
||||||
|
unpublishCustomWorldProfile,
|
||||||
|
listCustomWorldGallery,
|
||||||
|
getCustomWorldGalleryDetail,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type { CustomWorldLibraryEntry };
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default defineConfig(({mode}) => {
|
|||||||
entries: ['index.html'],
|
entries: ['index.html'],
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 750,
|
chunkSizeWarningLimit: 800,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
|||||||
Reference in New Issue
Block a user