This commit is contained in:
2026-04-14 20:43:46 +08:00
39 changed files with 2971 additions and 940 deletions

View File

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

View File

@@ -109,6 +109,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
'20260409_007_sms_auth_events',
'20260409_008_auth_risk_blocks',
'20260413_009_custom_world_sessions',
'20260414_010_custom_world_gallery_metadata',
],
);

View File

@@ -206,4 +206,32 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
ON custom_world_sessions (user_id, updated_at DESC)`,
],
},
{
id: '20260414_010_custom_world_gallery_metadata',
name: 'custom world gallery metadata',
statements: [
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'draft'`,
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS published_at TEXT`,
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS author_display_name TEXT NOT NULL DEFAULT '玩家'`,
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS world_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS subtitle TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS summary_text TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS cover_image_src TEXT`,
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS theme_mode TEXT NOT NULL DEFAULT 'mythic'`,
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS playable_npc_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE custom_world_profiles
ADD COLUMN IF NOT EXISTS landmark_count INTEGER NOT NULL DEFAULT 0`,
`CREATE INDEX IF NOT EXISTS custom_world_profiles_published_idx
ON custom_world_profiles (visibility, published_at DESC, updated_at DESC)`,
],
},
];

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

@@ -7,12 +7,17 @@ import type {
} from '../../../packages/shared/src/contracts/runtime.js';
import {
type CustomWorldSessionRecord,
type CustomWorldGalleryCard,
type CustomWorldLibraryEntry,
type CustomWorldPublicationStatus,
DEFAULT_MUSIC_VOLUME,
SAVE_SNAPSHOT_VERSION,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js';
import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js';
const MAX_CUSTOM_WORLD_PROFILES = 12;
const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36;
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
@@ -28,9 +33,21 @@ type SettingsRow = QueryResultRow & {
musicVolume: number;
};
type ProfileRow = QueryResultRow & {
type CustomWorldEntryRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
payload: CustomWorldProfileRecord;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldLibraryEntry['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
type SessionRow = QueryResultRow & {
@@ -39,6 +56,22 @@ type SessionRow = QueryResultRow & {
updatedAt: string;
};
type CustomWorldCardRow = QueryResultRow & {
ownerUserId: string;
profileId: string;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;
updatedAt: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeMode: CustomWorldGalleryCard['themeMode'];
playableNpcCount: number;
landmarkCount: number;
};
export type RuntimeRepositoryPort = {
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
putSnapshot(
@@ -51,17 +84,25 @@ 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[]>;
listCustomWorldSessions(userId: string): Promise<CustomWorldSessionRecord[]>;
): Promise<CustomWorldLibraryEntry<CustomWorldProfileRecord>[]>;
listCustomWorldSessions(
userId: string,
): Promise<CustomWorldSessionRecord[]>;
getCustomWorldSession(
userId: string,
sessionId: string,
@@ -71,11 +112,118 @@ export type RuntimeRepositoryPort = {
sessionId: string,
session: CustomWorldSessionRecord,
): Promise<CustomWorldSessionRecord>;
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,
@@ -192,42 +340,92 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
}
async listCustomWorldProfiles(userId: string) {
const result = await this.db.query<ProfileRow>(
`SELECT payload_json AS payload,
updated_at AS "updatedAt"
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
LIMIT $2`,
[userId, MAX_CUSTOM_WORLD_PROFILES],
);
return result.rows.map((row: ProfileRow) => ({
...row.payload,
updatedAt: row.updatedAt,
}));
return result.rows.map((row) => toCustomWorldLibraryEntry(row));
}
async upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: CustomWorldProfileRecord,
profile: Record<string, unknown>,
authorDisplayName: string,
) {
const payload = {
...profile,
id: profileId,
};
const payload = normalizeStoredProfile(profileId, profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
VALUES ($1, $2, $3, $4)
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, profileId, payload, new Date().toISOString()],
updated_at = EXCLUDED.updated_at,
author_display_name = EXCLUDED.author_display_name,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
);
return this.listCustomWorldProfiles(userId);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after upsert');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async deleteCustomWorldProfile(userId: string, profileId: string) {
@@ -310,4 +508,169 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
updatedAt: session.updatedAt,
};
}
async publishCustomWorldProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredProfile(profileId, existingEntry.profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'published',
published_at = $1,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after publish');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async unpublishCustomWorldProfile(
userId: string,
profileId: string,
authorDisplayName: string,
) {
const existingEntry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!existingEntry) {
return null;
}
const payload = normalizeStoredProfile(profileId, existingEntry.profile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const now = new Date().toISOString();
await this.db.query(
`UPDATE custom_world_profiles
SET visibility = 'draft',
published_at = NULL,
updated_at = $1,
author_display_name = $2,
world_name = $3,
subtitle = $4,
summary_text = $5,
cover_image_src = $6,
theme_mode = $7,
playable_npc_count = $8,
landmark_count = $9
WHERE user_id = $10
AND profile_id = $11`,
[
now,
authorDisplayName || '玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
userId,
profileId,
],
);
const entry = await this.findCustomWorldProfileEntry(userId, profileId);
if (!entry) {
throw new Error('failed to resolve custom world after unpublish');
}
return {
entry,
entries: await this.listCustomWorldProfiles(userId),
};
}
async listPublishedCustomWorldGallery() {
const result = await this.db.query<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

@@ -5,6 +5,10 @@ import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
CustomWorldGalleryDetailResponse,
CustomWorldGalleryResponse,
CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse,
RuntimeSettings,
SavedGameSnapshotInput,
} from '../../../packages/shared/src/contracts/runtime.js';
@@ -109,6 +113,15 @@ function readParam(param: string | string[] | undefined) {
return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || '';
}
async function resolveAuthDisplayName(context: AppContext, userId: string) {
const user = await context.userRepository.findById(userId);
if (!user) {
throw notFound('user not found');
}
return user.displayName?.trim() || '玩家';
}
export function createRuntimeRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
@@ -224,11 +237,55 @@ export function createRuntimeRoutes(context: AppContext) {
'/runtime/custom-world-library',
routeMeta({ operation: 'runtime.customWorldLibrary.list' }),
asyncHandler(async (request, response) => {
sendApiResponse(response, {
profiles: await context.runtimeRepository.listCustomWorldProfiles(
request.userId!,
),
});
sendApiResponse(
response,
{
entries: await context.runtimeRepository.listCustomWorldProfiles(
request.userId!,
),
} satisfies CustomWorldLibraryResponse,
);
}),
);
router.get(
'/runtime/custom-world-gallery',
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
asyncHandler(async (_request, response) => {
sendApiResponse(
response,
{
entries: await context.runtimeRepository.listPublishedCustomWorldGallery(),
} satisfies CustomWorldGalleryResponse,
);
}),
);
router.get(
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
asyncHandler(async (request, response) => {
const ownerUserId = readParam(request.params.ownerUserId);
const profileId = readParam(request.params.profileId);
if (!ownerUserId || !profileId) {
throw badRequest('ownerUserId and profileId are required');
}
const entry =
await context.runtimeRepository.getPublishedCustomWorldGalleryDetail(
ownerUserId,
profileId,
);
if (!entry) {
throw notFound('public custom world not found');
}
sendApiResponse(
response,
{
entry,
} satisfies CustomWorldGalleryDetailResponse,
);
}),
);
@@ -241,13 +298,19 @@ export function createRuntimeRoutes(context: AppContext) {
throw badRequest('profileId is required');
}
const payload = customWorldProfileSchema.parse(request.body);
sendApiResponse(response, {
profiles: await context.runtimeRepository.upsertCustomWorldProfile(
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
sendApiResponse(
response,
await context.runtimeRepository.upsertCustomWorldProfile(
request.userId!,
profileId,
jsonClone(payload.profile),
authorDisplayName,
),
});
);
}),
);
@@ -259,12 +322,75 @@ export function createRuntimeRoutes(context: AppContext) {
if (!profileId) {
throw badRequest('profileId is required');
}
sendApiResponse(response, {
profiles: await context.runtimeRepository.deleteCustomWorldProfile(
sendApiResponse(
response,
{
entries: await context.runtimeRepository.deleteCustomWorldProfile(
request.userId!,
profileId,
),
} satisfies CustomWorldLibraryResponse,
);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/publish',
routeMeta({ operation: 'runtime.customWorldLibrary.publish' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
const mutation =
await context.runtimeRepository.publishCustomWorldProfile(
request.userId!,
profileId,
),
});
authorDisplayName,
);
if (!mutation) {
throw notFound('custom world not found');
}
sendApiResponse(
response,
mutation satisfies CustomWorldLibraryMutationResponse,
);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/unpublish',
routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
const authorDisplayName = await resolveAuthDisplayName(
context,
request.userId!,
);
const mutation =
await context.runtimeRepository.unpublishCustomWorldProfile(
request.userId!,
profileId,
authorDisplayName,
);
if (!mutation) {
throw notFound('custom world not found');
}
sendApiResponse(
response,
mutation satisfies CustomWorldLibraryMutationResponse,
);
}),
);

View File

@@ -929,14 +929,7 @@ export class CustomWorldAgentDraftCompiler {
]
.filter(Boolean)
.join(' / '),
summary:
assetHeadline.status === 'complete'
? clampText(`${character.summary}(核心动作已就绪)`, 180)
: assetHeadline.status === 'visual_ready'
? clampText(`${character.summary}(主图已就绪)`, 180)
: assetHeadline.status === 'animations_ready'
? clampText(`${character.summary}(动作补齐中)`, 180)
: clampText(`${character.summary}(待生成主图)`, 180),
summary: clampText(character.summary, 180),
linkedIds: [...character.threadIds, ...linkedLandmarks].slice(0, 6),
sections: [
buildSection('name', '角色名', character.name),

View File

@@ -2,7 +2,10 @@ import type {
CustomWorldAgentStage,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js';
import type {
CustomWorldLibraryEntry,
CustomWorldProfileRecord,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import {
@@ -139,6 +142,18 @@ function resolvePublishedCover(profile: Record<string, unknown>) {
return toText(camp?.imageSrc) || toText(leadNpc?.imageSrc) || null;
}
function isLibraryEntry(
value: unknown,
): value is CustomWorldLibraryEntry<CustomWorldProfileRecord> {
const record = toRecord(value);
return (
Boolean(record) &&
typeof record.ownerUserId === 'string' &&
typeof record.profileId === 'string' &&
Boolean(toRecord(record.profile))
);
}
export async function listCustomWorldWorkSummaries(
userId: string,
dependencies: {
@@ -182,12 +197,16 @@ export async function listCustomWorldWorkSummaries(
});
const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => {
const profileRecord = profile as CustomWorldProfileRecord &
Record<string, unknown>;
const libraryEntry = isLibraryEntry(profile) ? profile : null;
const profileRecord = (
libraryEntry?.profile ?? profile
) as CustomWorldProfileRecord & Record<string, unknown>;
const playableNpcs = toRecordArray(profileRecord.playableNpcs);
const landmarks = toRecordArray(profileRecord.landmarks);
const updatedAt =
toText(profileRecord.updatedAt) || new Date().toISOString();
(libraryEntry ? toText(libraryEntry.updatedAt) : '') ||
toText(profileRecord.updatedAt) ||
new Date().toISOString();
const roleVisualReadyCount = playableNpcs.filter(
(entry) =>
Boolean(toText(entry.imageSrc)) &&
@@ -201,17 +220,36 @@ export async function listCustomWorldWorkSummaries(
workId: `published:${toText(profileRecord.id) || updatedAt}`,
sourceType: 'published_profile',
status: 'published',
title: toText(profileRecord.name) || '未命名世界',
subtitle: toText(profileRecord.subtitle) || '已发布作品',
title:
(libraryEntry ? toText(libraryEntry.worldName) : '') ||
toText(profileRecord.name) ||
'未命名世界',
subtitle:
(libraryEntry ? toText(libraryEntry.subtitle) : '') ||
toText(profileRecord.subtitle) ||
'已保存作品',
summary:
toText(profileRecord.summary) || '这个世界已经可以直接进入体验。',
coverImageSrc: resolvePublishedCover(profileRecord),
(libraryEntry ? toText(libraryEntry.summaryText) : '') ||
toText(profileRecord.summary) ||
'这个世界已经可以直接进入体验。',
coverImageSrc:
(libraryEntry ? libraryEntry.coverImageSrc : null) ||
resolvePublishedCover(profileRecord),
updatedAt,
publishedAt: toText(profileRecord.publishedAt) || updatedAt,
publishedAt:
(libraryEntry ? toText(libraryEntry.publishedAt) : '') ||
toText(profileRecord.publishedAt) ||
updatedAt,
stage: 'published',
stageLabel: '已发布',
playableNpcCount: playableNpcs.length,
landmarkCount: landmarks.length,
playableNpcCount:
(libraryEntry?.playableNpcCount ?? 0) > 0
? libraryEntry!.playableNpcCount
: playableNpcs.length,
landmarkCount:
(libraryEntry?.landmarkCount ?? 0) > 0
? libraryEntry!.landmarkCount
: landmarks.length,
roleVisualReadyCount,
roleAnimationReadyCount,
roleAssetSummaryLabel:
@@ -221,7 +259,10 @@ export async function listCustomWorldWorkSummaries(
? `主图已就绪 ${roleVisualReadyCount}`
: null,
sessionId: null,
profileId: toText(profileRecord.id) || null,
profileId:
(libraryEntry ? toText(libraryEntry.profileId) : '') ||
toText(profileRecord.id) ||
null,
canResume: false,
canEnterWorld: true,
};