This commit is contained in:
@@ -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, []);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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)`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
109
server-node/src/repositories/customWorldLibraryMetadata.ts
Normal file
109
server-node/src/repositories/customWorldLibraryMetadata.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
CustomWorldProfileRecord,
|
||||
CustomWorldThemeMode,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function readArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function readImageSrc(value: unknown) {
|
||||
return readString(value) || null;
|
||||
}
|
||||
|
||||
function detectThemeMode(
|
||||
profile: Pick<
|
||||
CustomWorldProfileRecord,
|
||||
| 'settingText'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
>,
|
||||
): CustomWorldThemeMode {
|
||||
const semanticAnchor = isRecord(profile.ownedSettingLayers)
|
||||
&& isRecord(profile.ownedSettingLayers.semanticAnchor)
|
||||
? profile.ownedSettingLayers.semanticAnchor
|
||||
: null;
|
||||
const expressionProfile = isRecord(profile.ownedSettingLayers)
|
||||
&& isRecord(profile.ownedSettingLayers.expressionProfile)
|
||||
? profile.ownedSettingLayers.expressionProfile
|
||||
: null;
|
||||
const source = [
|
||||
readString(profile.settingText),
|
||||
readString(profile.summary),
|
||||
readString(profile.tone),
|
||||
readString(profile.playerGoal),
|
||||
...readArray(semanticAnchor?.genreSignals).map((value) => readString(value)),
|
||||
...readArray(semanticAnchor?.conflictForms).map((value) => readString(value)),
|
||||
...readArray(semanticAnchor?.institutionTypes).map((value) => readString(value)),
|
||||
...readArray(semanticAnchor?.tabooTypes).map((value) => readString(value)),
|
||||
...readArray(semanticAnchor?.carrierTypes).map((value) => readString(value)),
|
||||
...readArray(semanticAnchor?.forceSystemTypes).map((value) => readString(value)),
|
||||
...readArray(semanticAnchor?.atmosphereTags).map((value) => readString(value)),
|
||||
...readArray(expressionProfile?.presentationTone).map((value) => readString(value)),
|
||||
].join(' ');
|
||||
|
||||
if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina';
|
||||
if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide';
|
||||
if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift';
|
||||
if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane';
|
||||
if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial';
|
||||
|
||||
return 'mythic';
|
||||
}
|
||||
|
||||
export function buildCustomWorldCoverImageSrc(profile: CustomWorldProfileRecord) {
|
||||
const explicitCampImage = isRecord(profile.camp)
|
||||
? readImageSrc(profile.camp.imageSrc)
|
||||
: null;
|
||||
if (explicitCampImage) {
|
||||
return explicitCampImage;
|
||||
}
|
||||
|
||||
const landmarkImage = readArray(profile.landmarks)
|
||||
.map((landmark) => (isRecord(landmark) ? readImageSrc(landmark.imageSrc) : null))
|
||||
.find(Boolean);
|
||||
if (landmarkImage) {
|
||||
return landmarkImage;
|
||||
}
|
||||
|
||||
const playableImage = readArray(profile.playableNpcs)
|
||||
.map((role) => (isRecord(role) ? readImageSrc(role.imageSrc) : null))
|
||||
.find(Boolean);
|
||||
if (playableImage) {
|
||||
return playableImage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractCustomWorldLibraryMetadata(profile: CustomWorldProfileRecord) {
|
||||
return {
|
||||
worldName: readString(profile.name, '未命名世界'),
|
||||
subtitle: readString(profile.subtitle),
|
||||
summaryText: readString(profile.summary),
|
||||
coverImageSrc: buildCustomWorldCoverImageSrc(profile),
|
||||
themeMode: detectThemeMode({
|
||||
settingText: profile.settingText,
|
||||
summary: profile.summary,
|
||||
tone: profile.tone,
|
||||
playerGoal: profile.playerGoal,
|
||||
templateWorldType: profile.templateWorldType,
|
||||
compatibilityTemplateWorldType: profile.compatibilityTemplateWorldType,
|
||||
ownedSettingLayers: profile.ownedSettingLayers,
|
||||
}),
|
||||
playableNpcCount: readArray(profile.playableNpcs).length,
|
||||
landmarkCount: readArray(profile.landmarks).length,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
import type { QueryResultRow } from 'pg';
|
||||
|
||||
import {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user