update: 表改动 主页改动
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
victo
2026-04-14 18:58:33 +08:00
parent 0981d6ee1b
commit 3d6f31433a
37 changed files with 2594 additions and 699 deletions

View File

@@ -8,3 +8,9 @@ VOLCENGINE_ACCESS_KEY_ID="AKLTZWFjMmYzZTdjZTIxNDRiNTkzMTZiMTk2NzVmNTUxOGI"
VOLCENGINE_SECRET_ACCESS_KEY="TURRMk56bGhZalE0TjJReE5ERmpNMkpoTUdaa1lqRmtaVGt5TVRrM1lXSQ=="
WECHAT_AUTH_ENABLED="true"
WECHAT_AUTH_PROVIDER="mock"
SMS_AUTH_ENABLED="true"
SMS_AUTH_PROVIDER="mock"
SMS_AUTH_MOCK_VERIFY_CODE="123456"
VITE_AUTH_ALLOW_DEV_GUEST="false"

View File

@@ -34,14 +34,64 @@ export type BasicOkResult = {
ok: true;
};
export type CustomWorldPublicationStatus = 'draft' | 'published';
export type CustomWorldThemeMode =
| 'martial'
| 'arcane'
| 'machina'
| 'tide'
| 'rift'
| 'mythic';
export type CustomWorldProfileRecord = JsonObject & {
id?: string;
};
export type CustomWorldLibraryEntry<
TProfile = CustomWorldProfileRecord,
> = {
ownerUserId: string;
profileId: string;
profile: TProfile;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldThemeMode;
playableNpcCount: number;
landmarkCount: number;
};
export type CustomWorldGalleryCard = Omit<
CustomWorldLibraryEntry<never>,
'profile'
>;
export type CustomWorldLibraryResponse<
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;

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

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

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

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

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

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

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

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

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

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

View 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.

View File

@@ -1565,9 +1565,164 @@ test('runtime persistence is isolated by user', async () => {
},
);
const userBLibraryPayload = (await userBLibrary.json()) as {
profiles: unknown[];
entries: unknown[];
};
assert.deepEqual(userBLibraryPayload.profiles, []);
assert.deepEqual(userBLibraryPayload.entries, []);
});
});
test('custom worlds stay private until published and then appear in the public gallery', async () => {
await withTestServer('custom-world-gallery', async ({ baseUrl }) => {
const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123');
const viewer = await authEntry(baseUrl, 'gallery_viewer', 'secret123');
const upsertResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-a`,
withBearer(owner.token, {
method: 'PUT',
body: JSON.stringify({
profile: {
id: 'world-a',
name: '裂桥前线',
subtitle: '边境上空的断层回响',
summary: '围绕裂桥哨线与失序潮汐展开的前线世界。',
tone: '压迫、冷峻、持续失衡',
playerGoal: '在裂桥崩塌前守住归路',
majorFactions: ['裂桥守军'],
coreConflicts: ['断层外压正在逼近城线'],
playableNpcs: [
{
id: 'role-1',
name: '沈昼',
},
],
storyNpcs: [],
landmarks: [
{
id: 'landmark-1',
name: '裂桥前哨',
description: '裂谷边缘的前线哨卡。',
dangerLevel: '高',
sceneNpcIds: [],
connections: [],
},
],
},
}),
}),
);
const upsertPayload = (await upsertResponse.json()) as {
entry: {
visibility: 'draft' | 'published';
authorDisplayName: string;
};
entries: unknown[];
};
assert.equal(upsertResponse.status, 200);
assert.equal(upsertPayload.entry.visibility, 'draft');
assert.equal(upsertPayload.entry.authorDisplayName, 'gallery_owner');
const galleryBeforePublish = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const galleryBeforePayload = (await galleryBeforePublish.json()) as {
entries: unknown[];
};
assert.deepEqual(galleryBeforePayload.entries, []);
const publishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-a/publish`,
withBearer(owner.token, {
method: 'POST',
}),
);
const publishPayload = (await publishResponse.json()) as {
entry: {
visibility: 'draft' | 'published';
publishedAt: string | null;
};
};
assert.equal(publishResponse.status, 200);
assert.equal(publishPayload.entry.visibility, 'published');
assert.ok(publishPayload.entry.publishedAt);
const galleryAfterPublish = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const galleryAfterPayload = (await galleryAfterPublish.json()) as {
entries: Array<{
ownerUserId: string;
profileId: string;
worldName: string;
authorDisplayName: string;
}>;
};
assert.equal(galleryAfterPublish.status, 200);
assert.equal(galleryAfterPayload.entries.length, 1);
assert.equal(galleryAfterPayload.entries[0]?.worldName, '裂桥前线');
assert.equal(galleryAfterPayload.entries[0]?.authorDisplayName, 'gallery_owner');
const galleryDetail = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const galleryDetailPayload = (await galleryDetail.json()) as {
entry: {
worldName: string;
profile: {
name: string;
};
};
};
assert.equal(galleryDetail.status, 200);
assert.equal(galleryDetailPayload.entry.worldName, '裂桥前线');
assert.equal(galleryDetailPayload.entry.profile.name, '裂桥前线');
const unpublishResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-a/unpublish`,
withBearer(owner.token, {
method: 'POST',
}),
);
const unpublishPayload = (await unpublishResponse.json()) as {
entry: {
visibility: 'draft' | 'published';
};
};
assert.equal(unpublishResponse.status, 200);
assert.equal(unpublishPayload.entry.visibility, 'draft');
const galleryAfterUnpublish = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const galleryAfterUnpublishPayload = (await galleryAfterUnpublish.json()) as {
entries: unknown[];
};
assert.deepEqual(galleryAfterUnpublishPayload.entries, []);
});
});

View File

@@ -108,6 +108,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
'20260409_006_auth_audit_logs',
'20260409_007_sms_auth_events',
'20260409_008_auth_risk_blocks',
'20260414_009_custom_world_gallery_metadata',
],
);

View File

@@ -189,4 +189,32 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
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)`,
],
},
];

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

View File

@@ -1,17 +1,22 @@
import type { QueryResultRow } from 'pg';
import {
DEFAULT_MUSIC_VOLUME,
SAVE_SNAPSHOT_VERSION,
} from '../../../packages/shared/src/contracts/runtime.js';
import type {
CustomWorldProfileRecord,
RuntimeSettings,
SavedGameSnapshot,
} 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 { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
const MAX_CUSTOM_WORLD_PROFILES = 12;
const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36;
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
@@ -27,8 +32,37 @@ type SettingsRow = QueryResultRow & {
musicVolume: number;
};
type ProfileRow = QueryResultRow & {
type CustomWorldEntryRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
payload: CustomWorldProfileRecord;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldLibraryEntry['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
type 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 = {
@@ -43,21 +77,134 @@ export type RuntimeRepositoryPort = {
userId: string,
settings: RuntimeSettings,
): Promise<RuntimeSettings>;
listCustomWorldProfiles(userId: string): Promise<CustomWorldProfileRecord[]>;
listCustomWorldProfiles(
userId: string,
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
): Promise<CustomWorldProfileRecord[]>;
authorDisplayName: string,
): Promise<{
entry: CustomWorldLibraryEntry<CustomWorldProfileRecord>;
entries: CustomWorldLibraryEntry<CustomWorldProfileRecord>[];
}>;
deleteCustomWorldProfile(
userId: 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 {
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) {
const result = await this.db.query<SnapshotRow>(
`SELECT version,
@@ -174,8 +321,21 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
}
async listCustomWorldProfiles(userId: string) {
const result = await this.db.query<ProfileRow>(
`SELECT payload_json AS payload
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
ORDER BY updated_at DESC
@@ -183,29 +343,71 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
[userId, MAX_CUSTOM_WORLD_PROFILES],
);
return result.rows.map((row: ProfileRow) => row.payload);
return result.rows.map((row) => toCustomWorldLibraryEntry(row));
}
async upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: CustomWorldProfileRecord,
authorDisplayName: string,
) {
const payload = {
...profile,
id: profileId,
};
const payload = normalizeStoredProfile(profileId, profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
VALUES ($1, $2, $3, $4)
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, profileId, payload, new Date().toISOString()],
updated_at = EXCLUDED.updated_at,
author_display_name = EXCLUDED.author_display_name,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
);
return this.listCustomWorldProfiles(userId);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after upsert');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async deleteCustomWorldProfile(userId: string, profileId: string) {
@@ -217,4 +419,169 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
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;
}
}

View File

@@ -4,6 +4,10 @@ import { z } from 'zod';
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
CustomWorldGalleryDetailResponse,
CustomWorldGalleryResponse,
CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse,
RuntimeSettings,
SavedGameSnapshotInput,
} from '../../../packages/shared/src/contracts/runtime.js';
@@ -27,6 +31,8 @@ import {
prepareEventStreamResponse,
sendApiResponse,
} from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
generateCharacterChatSummaryFromOrchestrator,
@@ -34,8 +40,6 @@ import {
streamNpcChatDialogueFromOrchestrator,
streamNpcRecruitDialogueFromOrchestrator,
} from '../modules/ai/chatOrchestrator.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
@@ -104,6 +108,15 @@ function readParam(param: string | string[] | undefined) {
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
}
async function resolveAuthDisplayName(context: AppContext, userId: string) {
const user = await context.userRepository.findById(userId);
if (!user) {
throw notFound('user not found');
}
return user.displayName?.trim() || '玩家';
}
export function createRuntimeRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
@@ -202,11 +215,55 @@ export function createRuntimeRoutes(context: AppContext) {
'/runtime/custom-world-library',
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
asyncHandler(async (request, response) => {
sendApiResponse(response, {
profiles: await context.runtimeRepository.listCustomWorldProfiles(
request.userId!,
),
});
sendApiResponse(
response,
{
entries: await context.runtimeRepository.listCustomWorldProfiles(
request.userId!,
),
} satisfies CustomWorldLibraryResponse,
);
}),
);
router.get(
'/runtime/custom-world-gallery',
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
asyncHandler(async (_request, response) => {
sendApiResponse(
response,
{
entries: await context.runtimeRepository.listPublishedCustomWorldGallery(),
} satisfies CustomWorldGalleryResponse,
);
}),
);
router.get(
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
asyncHandler(async (request, response) => {
const ownerUserId = readParam(request.params.ownerUserId);
const profileId = readParam(request.params.profileId);
if (!ownerUserId || !profileId) {
throw badRequest('ownerUserId and profileId are required');
}
const entry =
await context.runtimeRepository.getPublishedCustomWorldGalleryDetail(
ownerUserId,
profileId,
);
if (!entry) {
throw notFound('public custom world not found');
}
sendApiResponse(
response,
{
entry,
} satisfies CustomWorldGalleryDetailResponse,
);
}),
);
@@ -219,13 +276,19 @@ export function createRuntimeRoutes(context: AppContext) {
throw badRequest('profileId is required');
}
const payload = customWorldProfileSchema.parse(request.body);
sendApiResponse(response, {
profiles: await context.runtimeRepository.upsertCustomWorldProfile(
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
sendApiResponse(
response,
await context.runtimeRepository.upsertCustomWorldProfile(
request.userId!,
profileId,
jsonClone(payload.profile),
authorDisplayName,
),
});
);
}),
);
@@ -237,12 +300,75 @@ export function createRuntimeRoutes(context: AppContext) {
if (!profileId) {
throw badRequest('profileId is required');
}
sendApiResponse(response, {
profiles: await context.runtimeRepository.deleteCustomWorldProfile(
sendApiResponse(
response,
{
entries: await context.runtimeRepository.deleteCustomWorldProfile(
request.userId!,
profileId,
),
} satisfies CustomWorldLibraryResponse,
);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/publish',
routeMeta({ operation: 'runtime.customWorldLibrary.publish' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
const mutation =
await context.runtimeRepository.publishCustomWorldProfile(
request.userId!,
profileId,
),
});
authorDisplayName,
);
if (!mutation) {
throw notFound('custom world not found');
}
sendApiResponse(
response,
mutation satisfies CustomWorldLibraryMutationResponse,
);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/unpublish',
routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
const mutation =
await context.runtimeRepository.unpublishCustomWorldProfile(
request.userId!,
profileId,
authorDisplayName,
);
if (!mutation) {
throw notFound('custom world not found');
}
sendApiResponse(
response,
mutation satisfies CustomWorldLibraryMutationResponse,
);
}),
);

View File

@@ -20,12 +20,8 @@ import {
import {
type CustomWorldSceneImageResult,
generateCustomWorldSceneImage,
} from '../services/ai';
} from '../services/aiService';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
} from '../services/customWorld';
import {
AnimationState,
CustomWorldLandmark,

View File

@@ -213,7 +213,7 @@ export function CustomWorldResultView({
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
>
<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>
</div>
</button>

View File

@@ -2,6 +2,7 @@ import {AnimatePresence, motion} from 'motion/react';
import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react';
import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
import {getWorldCampScenePreset} from '../data/scenePresets';
import {BottomTab} from '../hooks/useGameFlow';
import {
@@ -55,6 +56,7 @@ interface GameShellStoryProps {
interface GameShellEntryProps {
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
@@ -208,6 +210,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
} = story;
const {
hasSavedGame,
savedSnapshot,
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
@@ -272,7 +275,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
!gameState.playerCharacter;
const hideSelectionHero =
gameState.currentScene === 'Selection' &&
selectionStage !== 'start';
selectionStage !== 'platform';
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const dialogueIndicator = useMemo(() => {
@@ -428,6 +431,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
setSelectionStage={setSelectionStage}
gameState={gameState}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleCustomWorldSelect={handleCustomWorldSelect}
@@ -447,7 +451,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
customWorldProfile={gameState.customWorldProfile}
onBack={() => {
handleBackToWorldSelect();
setSelectionStage('world');
setSelectionStage('platform');
}}
onConfirm={handleCharacterSelect}
/>

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import {
AUTH_STATE_EVENT,
@@ -30,6 +30,7 @@ import {
startWechatLogin,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
import { AuthUiContext } from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
@@ -61,6 +62,7 @@ export function AuthGate({ children }: AuthGateProps) {
const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [showAccountModal, setShowAccountModal] = useState(false);
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
@@ -304,6 +306,19 @@ export function AuthGate({ children }: AuthGateProps) {
};
}, [showAccountModal, status]);
const authUiValue = useMemo(
() => ({
user,
openAccountModal: () => setShowAccountModal(true),
logout: async () => {
await logoutAuthUser();
setShowAccountModal(false);
},
setGlobalAccountActionsVisible: setShowGlobalAccountActions,
}),
[user],
);
if (status === 'checking') {
return (
<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 (
<div className="relative">
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
<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">
<button
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"
onClick={() => setShowAccountModal(true)}
>
{user.displayName}
</button>
<button
type="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"
onClick={() => {
void logoutAuthUser();
}}
>
退
</button>
</div>
</div>
<AccountModal
user={user}
isOpen={showAccountModal}
riskBlocks={riskBlocks}
sessions={sessions}
auditLogs={auditLogs}
loadingRiskBlocks={loadingRiskBlocks}
loadingSessions={loadingSessions}
loadingAuditLogs={loadingAuditLogs}
onClose={() => setShowAccountModal(false)}
onLogout={async () => {
await logoutAuthUser();
setShowAccountModal(false);
}}
onRefreshRiskBlocks={async () => {
setLoadingRiskBlocks(true);
try {
setRiskBlocks(await getAuthRiskBlocks());
} catch (blockError) {
setError(
blockError instanceof Error
? blockError.message
: '读取安全状态失败,请稍后再试。',
);
} finally {
setLoadingRiskBlocks(false);
}
}}
onLiftRiskBlock={async (scopeType) => {
try {
await liftAuthRiskBlock(scopeType);
setRiskBlocks(await getAuthRiskBlocks());
setAuditLogs(await getAuthAuditLogs());
} catch (liftError) {
setError(
liftError instanceof Error
? liftError.message
: '解除保护失败,请稍后再试。',
);
}
}}
onRefreshSessions={async () => {
setLoadingSessions(true);
try {
setSessions(await getAuthSessions());
} catch (sessionError) {
setError(
sessionError instanceof Error
? sessionError.message
: '读取登录设备失败,请稍后再试。',
);
} finally {
setLoadingSessions(false);
}
}}
onRefreshAuditLogs={async () => {
setLoadingAuditLogs(true);
try {
setAuditLogs(await getAuthAuditLogs());
} catch (auditError) {
setError(
auditError instanceof Error
? auditError.message
: '读取账号操作记录失败,请稍后再试。',
);
} finally {
setLoadingAuditLogs(false);
}
}}
onRevokeSession={async (sessionId) => {
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
);
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
setError(
revokeError instanceof Error
? revokeError.message
: '移除登录设备失败,请稍后再试。',
);
}
}}
onLogoutAll={async () => {
await logoutAllAuthSessions();
setShowAccountModal(false);
}}
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
onSendChangePhoneCode={async (phone, captcha) => {
try {
const result = await sendPhoneLoginCode(
phone,
'change_phone',
captcha,
);
setChangePhoneCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setChangePhoneCaptchaChallenge(captchaChallenge);
<AuthUiContext.Provider value={authUiValue}>
<div className="relative">
{showGlobalAccountActions ? (
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
<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">
<button
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"
onClick={() => setShowAccountModal(true)}
>
{user.displayName}
</button>
<button
type="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"
onClick={() => {
void logoutAuthUser();
}}
>
退
</button>
</div>
</div>
) : null}
<AccountModal
user={user}
isOpen={showAccountModal}
riskBlocks={riskBlocks}
sessions={sessions}
auditLogs={auditLogs}
loadingRiskBlocks={loadingRiskBlocks}
loadingSessions={loadingSessions}
loadingAuditLogs={loadingAuditLogs}
onClose={() => setShowAccountModal(false)}
onLogout={async () => {
await logoutAuthUser();
setShowAccountModal(false);
}}
onRefreshRiskBlocks={async () => {
setLoadingRiskBlocks(true);
try {
setRiskBlocks(await getAuthRiskBlocks());
} catch (blockError) {
setError(
blockError instanceof Error
? blockError.message
: '读取安全状态失败,请稍后再试。',
);
} finally {
setLoadingRiskBlocks(false);
}
throw sendError;
}
}}
onChangePhone={async (phone, code) => {
const nextUser = await changePhoneNumber(phone, code);
setChangePhoneCaptchaChallenge(null);
setUser(nextUser);
}}
/>
{children}
</div>
}}
onLiftRiskBlock={async (scopeType) => {
try {
await liftAuthRiskBlock(scopeType);
setRiskBlocks(await getAuthRiskBlocks());
setAuditLogs(await getAuthAuditLogs());
} catch (liftError) {
setError(
liftError instanceof Error
? liftError.message
: '解除保护失败,请稍后再试。',
);
}
}}
onRefreshSessions={async () => {
setLoadingSessions(true);
try {
setSessions(await getAuthSessions());
} catch (sessionError) {
setError(
sessionError instanceof Error
? sessionError.message
: '读取登录设备失败,请稍后再试。',
);
} finally {
setLoadingSessions(false);
}
}}
onRefreshAuditLogs={async () => {
setLoadingAuditLogs(true);
try {
setAuditLogs(await getAuthAuditLogs());
} catch (auditError) {
setError(
auditError instanceof Error
? auditError.message
: '读取账号操作记录失败,请稍后再试。',
);
} finally {
setLoadingAuditLogs(false);
}
}}
onRevokeSession={async (sessionId) => {
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
);
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
setError(
revokeError instanceof Error
? revokeError.message
: '移除登录设备失败,请稍后再试。',
);
}
}}
onLogoutAll={async () => {
await logoutAllAuthSessions();
setShowAccountModal(false);
}}
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
onSendChangePhoneCode={async (phone, captcha) => {
try {
const result = await sendPhoneLoginCode(
phone,
'change_phone',
captcha,
);
setChangePhoneCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setChangePhoneCaptchaChallenge(captchaChallenge);
}
throw sendError;
}
}}
onChangePhone={async (phone, code) => {
const nextUser = await changePhoneNumber(phone, code);
setChangePhoneCaptchaChallenge(null);
setUser(nextUser);
}}
/>
{children}
</div>
</AuthUiContext.Provider>
);
}

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

View File

@@ -1,4 +1,5 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/useGameFlow';
import type {
@@ -8,6 +9,7 @@ import type {
InventoryFlowUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type {
CompanionRenderState,
CustomWorldProfile,
@@ -17,11 +19,38 @@ import type {
} from '../../types';
import { UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
import { GameShellStoryPanels } from './GameShellStoryPanels';
import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow';
import type { SelectionStage } from './PreGameSelectionFlow';
import type { GameShellAdventureStatistics } from './types';
const CharacterSelectionFlow = lazy(async () => {
const module = await import('./CharacterSelectionFlow');
return {
default: module.CharacterSelectionFlow,
};
});
const PreGameSelectionFlow = lazy(async () => {
const module = await import('./PreGameSelectionFlow');
return {
default: module.PreGameSelectionFlow,
};
});
const GameShellStoryPanels = lazy(async () => {
const module = await import('./GameShellStoryPanels');
return {
default: module.GameShellStoryPanels,
};
});
function MainContentLoadingFallback({ label }: { label: string }) {
return (
<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({
gameState,
visibleGameState,
@@ -34,6 +63,7 @@ export function GameShellMainContent({
setSelectionStage,
isCharacterSelectionStage,
hasSavedGame,
savedSnapshot,
handleContinueGame,
handleStartNewGame,
handleCustomWorldSelect,
@@ -71,6 +101,7 @@ export function GameShellMainContent({
setSelectionStage: (stage: SelectionStage) => void;
isCharacterSelectionStage: boolean;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
@@ -110,15 +141,20 @@ export function GameShellMainContent({
>
<AnimatePresence mode="wait">
{!gameState.worldType && (
<PreGameSelectionFlow
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={gameState}
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
<Suspense
fallback={<MainContentLoadingFallback label="正在加载平台首页..." />}
>
<PreGameSelectionFlow
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={gameState}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
</Suspense>
)}
{gameState.worldType && !gameState.playerCharacter && (
@@ -129,50 +165,58 @@ export function GameShellMainContent({
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<CharacterSelectionFlow
worldType={gameState.worldType}
customWorldProfile={gameState.customWorldProfile}
onBack={() => {
handleBackToWorldSelect();
setSelectionStage('world');
}}
onConfirm={handleCharacterSelect}
/>
<Suspense
fallback={<MainContentLoadingFallback label="正在加载角色选择..." />}
>
<CharacterSelectionFlow
worldType={gameState.worldType}
customWorldProfile={gameState.customWorldProfile}
onBack={() => {
handleBackToWorldSelect();
setSelectionStage('platform');
}}
onConfirm={handleCharacterSelect}
/>
</Suspense>
</motion.div>
)}
{visibleGameState.playerCharacter && visibleCurrentStory && (
<motion.div key="story-flow" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex h-full min-h-0 flex-col">
<GameShellStoryPanels
visibleGameState={visibleGameState}
visibleCurrentStory={visibleCurrentStory}
isLoading={isLoading}
aiError={aiError}
bottomTab={bottomTab}
setBottomTab={setBottomTab}
displayedOptions={displayedOptions}
hideStoryOptions={hideStoryOptions}
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
goalUi={goalUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
openOverlayPanel={openOverlayPanel}
openCampModal={openCampModal}
openPartyMemberDetails={openPartyMemberDetails}
adventureStatistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={() => {
resetForSaveAndExit();
handleSaveAndExit();
}}
/>
<Suspense
fallback={<MainContentLoadingFallback label="正在加载冒险面板..." />}
>
<GameShellStoryPanels
visibleGameState={visibleGameState}
visibleCurrentStory={visibleCurrentStory}
isLoading={isLoading}
aiError={aiError}
bottomTab={bottomTab}
setBottomTab={setBottomTab}
displayedOptions={displayedOptions}
hideStoryOptions={hideStoryOptions}
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
goalUi={goalUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
openOverlayPanel={openOverlayPanel}
openCampModal={openCampModal}
openPartyMemberDetails={openPartyMemberDetails}
adventureStatistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={() => {
resetForSaveAndExit();
handleSaveAndExit();
}}
/>
</Suspense>
</motion.div>
)}
</AnimatePresence>

View File

@@ -1,11 +1,26 @@
import { lazy, Suspense, useEffect } from 'react';
import { UI_CHROME } from '../../uiAssets';
import { GameShellCanvasStage } from './GameShellCanvasStage';
import { useAuthUi } from '../auth/AuthUiContext';
import { GameShellMainContent } from './GameShellMainContent';
import { GameShellOverlays } from './GameShellOverlays';
import type { GameShellProps } from './types';
import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel';
const GameShellOverlays = lazy(async () => {
const module = await import('./GameShellOverlays');
return {
default: module.GameShellOverlays,
};
});
const GameShellCanvasStage = lazy(async () => {
const module = await import('./GameShellCanvasStage');
return {
default: module.GameShellCanvasStage,
};
});
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
const authUi = useAuthUi();
const {
gameState,
isLoading,
@@ -29,6 +44,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
} = story;
const {
hasSavedGame,
savedSnapshot,
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
@@ -80,6 +96,14 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
companions,
});
useEffect(() => {
authUi?.setGlobalAccountActionsVisible(Boolean(gameState.playerCharacter));
return () => {
authUi?.setGlobalAccountActionsVisible(true);
};
}, [authUi, gameState.playerCharacter]);
return (
<div
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',
}}
>
<GameShellCanvasStage
gameState={gameState}
visibleGameState={visibleGameState}
hideSelectionHero={hideSelectionHero}
canvasCompanionRenderStates={canvasCompanionRenderStates}
dialogueIndicator={dialogueIndicator}
sceneTransitionPhase={sceneTransitionPhase}
sceneTransitionToken={sceneTransitionToken}
setSelectedSceneEntity={setSelectedSceneEntity}
setIsMapOpen={setIsMapOpen}
setSceneTransitionDurations={setSceneTransitionDurations}
/>
<Suspense fallback={null}>
<GameShellCanvasStage
gameState={gameState}
visibleGameState={visibleGameState}
hideSelectionHero={hideSelectionHero}
canvasCompanionRenderStates={canvasCompanionRenderStates}
dialogueIndicator={dialogueIndicator}
sceneTransitionPhase={sceneTransitionPhase}
sceneTransitionToken={sceneTransitionToken}
setSelectedSceneEntity={setSelectedSceneEntity}
setIsMapOpen={setIsMapOpen}
setSceneTransitionDurations={setSceneTransitionDurations}
/>
</Suspense>
<GameShellMainContent
gameState={gameState}
@@ -114,6 +140,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
setSelectionStage={setSelectionStage}
isCharacterSelectionStage={isCharacterSelectionStage}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleCustomWorldSelect={handleCustomWorldSelect}
@@ -141,34 +168,35 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
handleSaveAndExit={handleSaveAndExit}
/>
<GameShellOverlays
gameState={gameState}
isLoading={isLoading}
isMapOpen={isMapOpen}
setIsMapOpen={setIsMapOpen}
npcUi={npcUi}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
overlayPanel={overlayPanel}
closeOverlayPanel={closeOverlayPanel}
openCampModal={openCampModal}
openPartyMemberDetails={openPartyMemberDetails}
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
selectedSceneEntity={selectedSceneEntity}
closeAdventureEntityModal={closeAdventureEntityModal}
shouldMountCampModal={shouldMountCampModal}
showTeamModal={showTeamModal}
closeCampModal={closeCampModal}
onBenchCompanion={onBenchCompanion}
onActivateRosterCompanion={onActivateRosterCompanion}
shouldMountMapModal={shouldMountMapModal}
handleMapTravelToScene={handleMapTravelToScene}
shouldMountCharacterChatModal={shouldMountCharacterChatModal}
shouldMountNpcModals={shouldMountNpcModals}
/>
<Suspense fallback={null}>
<GameShellOverlays
gameState={gameState}
isLoading={isLoading}
isMapOpen={isMapOpen}
setIsMapOpen={setIsMapOpen}
npcUi={npcUi}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
overlayPanel={overlayPanel}
closeOverlayPanel={closeOverlayPanel}
openCampModal={openCampModal}
openPartyMemberDetails={openPartyMemberDetails}
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
selectedSceneEntity={selectedSceneEntity}
closeAdventureEntityModal={closeAdventureEntityModal}
shouldMountCampModal={shouldMountCampModal}
showTeamModal={showTeamModal}
closeCampModal={closeCampModal}
onBenchCompanion={onBenchCompanion}
onActivateRosterCompanion={onActivateRosterCompanion}
shouldMountMapModal={shouldMountMapModal}
handleMapTravelToScene={handleMapTravelToScene}
shouldMountCharacterChatModal={shouldMountCharacterChatModal}
shouldMountNpcModals={shouldMountNpcModals}
/>
</Suspense>
</div>
);
}

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

View 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

View 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 '回响';
}
}

View File

@@ -7,10 +7,11 @@ import type {
QuestFlowUi,
StoryGenerationNpcUi,
} from '../../hooks/useStoryGeneration';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type {
Character,
CustomWorldProfile,
CompanionRenderState,
CustomWorldProfile,
GameState,
StoryMoment,
StoryOption,
@@ -43,6 +44,7 @@ export interface GameShellStoryProps {
export interface GameShellEntryProps {
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;

View File

@@ -153,7 +153,7 @@ export function useGameShellRuntimeViewModel(params: Pick<
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const hideSelectionHero =
gameState.currentScene === 'Selection' &&
shellViewModel.selectionStage !== 'start';
shellViewModel.selectionStage !== 'platform';
const dialogueIndicator = useMemo(
() =>

View File

@@ -30,7 +30,7 @@ export function useGameShellViewModel(params: {
characterChatModalOpen,
hasNpcModalOpen,
} = params;
const [selectionStage, setSelectionStage] = useState<SelectionStage>('start');
const [selectionStage, setSelectionStage] = useState<SelectionStage>('platform');
const [overlayPanel, setOverlayPanel] = useState<OverlayPanel>(null);
const [selectedSceneEntity, setSelectedSceneEntity] = useState<GameCanvasEntitySelection | null>(null);
const [showTeamModal, setShowTeamModal] = useState(false);
@@ -56,13 +56,13 @@ export function useGameShellViewModel(params: {
const openCampModal = () => setShowTeamModal(true);
const closeCampModal = () => setShowTeamModal(false);
const resetSelectionFlow = () => setSelectionStage('start');
const resetSelectionFlow = () => setSelectionStage('platform');
const resetForSaveAndExit = () => {
setSelectedSceneEntity(null);
setOverlayPanel(null);
setShowTeamModal(false);
setSelectionStage('start');
setSelectionStage('platform');
};
return {

View File

@@ -8,8 +8,8 @@ import {
putSaveSnapshot,
} from '../services/storageService';
import type { GameState, StoryMoment } from '../types';
import type { BottomTab } from './useGameFlow';
import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator';
import type { BottomTab } from './useGameFlow';
const AUTO_SAVE_DELAY_MS = 400;
@@ -296,6 +296,7 @@ export function useGamePersistence({
return {
hasSavedGame,
savedSnapshot,
isHydratingSnapshot,
isPersistingSnapshot,
persistenceError,

View File

@@ -158,6 +158,7 @@ export function useGameShellRuntime(): GameShellProps {
},
entry: {
hasSavedGame: persistence.hasSavedGame,
savedSnapshot: persistence.savedSnapshot,
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,

View File

@@ -1,5 +1,9 @@
import type {
BasicOkResult,
CustomWorldGalleryDetailResponse,
CustomWorldGalleryResponse,
CustomWorldLibraryEntry,
CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse,
RuntimeSettings,
} from '../../packages/shared/src/contracts/runtime';
@@ -117,14 +121,14 @@ export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}
options,
);
return Array.isArray(response?.profiles) ? response.profiles : [];
return Array.isArray(response?.entries) ? response.entries : [];
}
export async function upsertCustomWorldProfile(
profile: CustomWorldProfile,
options: RuntimeRequestOptions = {},
) {
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
const response = await requestRuntimeJson<CustomWorldLibraryMutationResponse<CustomWorldProfile>>(
`/custom-world-library/${encodeURIComponent(profile.id)}`,
{
method: 'PUT',
@@ -137,7 +141,10 @@ export async function upsertCustomWorldProfile(
options,
);
return Array.isArray(response?.profiles) ? response.profiles : [];
return {
entry: response.entry,
entries: Array.isArray(response?.entries) ? response.entries : [],
};
}
export async function deleteCustomWorldProfile(
@@ -151,7 +158,67 @@ export async function deleteCustomWorldProfile(
options,
);
return Array.isArray(response?.profiles) ? response.profiles : [];
return Array.isArray(response?.entries) ? response.entries : [];
}
export async function publishCustomWorldProfile(
profileId: string,
options: RuntimeRequestOptions = {},
) {
const response = await requestRuntimeJson<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 = {
@@ -163,4 +230,10 @@ export const runtimeStorageClient = {
listCustomWorldLibrary,
upsertCustomWorldProfile,
deleteCustomWorldProfile,
publishCustomWorldProfile,
unpublishCustomWorldProfile,
listCustomWorldGallery,
getCustomWorldGalleryDetail,
};
export type { CustomWorldLibraryEntry };

View File

@@ -38,7 +38,7 @@ export default defineConfig(({mode}) => {
entries: ['index.html'],
},
build: {
chunkSizeWarningLimit: 750,
chunkSizeWarningLimit: 800,
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.