1
This commit is contained in:
@@ -9,6 +9,7 @@ import type {
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
ProfileWalletLedgerEntry,
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshot,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
type CustomWorldPublicationStatus,
|
||||
type CustomWorldSessionRecord,
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
DEFAULT_PLATFORM_THEME,
|
||||
SAVE_SNAPSHOT_VERSION,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppDatabase } from '../db.js';
|
||||
@@ -39,6 +41,7 @@ type SnapshotRow = QueryResultRow & {
|
||||
|
||||
type SettingsRow = QueryResultRow & {
|
||||
musicVolume: number;
|
||||
platformTheme: RuntimeSettings['platformTheme'];
|
||||
};
|
||||
|
||||
type CustomWorldEntryRow = QueryResultRow & {
|
||||
@@ -127,6 +130,23 @@ type ProfileWorldSnapshotMeta = {
|
||||
worldSubtitle: string;
|
||||
};
|
||||
|
||||
type ProfileSaveArchiveRow = QueryResultRow & {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
savedAt: string;
|
||||
bottomTab: string;
|
||||
gameState: unknown;
|
||||
currentStory: unknown;
|
||||
};
|
||||
|
||||
type ProfileSaveArchiveMeta = Omit<ProfileSaveArchiveSummary, 'lastPlayedAt'>;
|
||||
|
||||
export type RuntimeRepositoryPort = {
|
||||
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
|
||||
putSnapshot(
|
||||
@@ -136,6 +156,14 @@ export type RuntimeRepositoryPort = {
|
||||
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary>;
|
||||
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]>;
|
||||
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse>;
|
||||
listProfileSaveArchives(userId: string): Promise<ProfileSaveArchiveSummary[]>;
|
||||
resumeProfileSaveArchive(
|
||||
userId: string,
|
||||
worldKey: string,
|
||||
): Promise<{
|
||||
entry: ProfileSaveArchiveSummary;
|
||||
snapshot: SavedSnapshot;
|
||||
} | null>;
|
||||
deleteSnapshot(userId: string): Promise<void>;
|
||||
getSettings(userId: string): Promise<RuntimeSettings>;
|
||||
putSettings(
|
||||
@@ -313,6 +341,10 @@ function normalizePlatformBrowseHistoryWriteEntry(
|
||||
};
|
||||
}
|
||||
|
||||
function readSavedStoryText(value: unknown) {
|
||||
return readString(asRecord(value)?.text);
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
@@ -600,6 +632,90 @@ function toProfilePlayedWorkSummary(
|
||||
};
|
||||
}
|
||||
|
||||
function toProfileSaveArchiveSummary(
|
||||
row: Pick<
|
||||
ProfileSaveArchiveRow,
|
||||
| 'worldKey'
|
||||
| 'ownerUserId'
|
||||
| 'profileId'
|
||||
| 'worldType'
|
||||
| 'worldName'
|
||||
| 'subtitle'
|
||||
| 'summaryText'
|
||||
| 'coverImageSrc'
|
||||
| 'savedAt'
|
||||
>,
|
||||
): ProfileSaveArchiveSummary {
|
||||
const subtitle = row.subtitle || '';
|
||||
return {
|
||||
worldKey: row.worldKey,
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldType: row.worldType,
|
||||
worldName: row.worldName || '未命名游戏',
|
||||
subtitle,
|
||||
summaryText: row.summaryText || subtitle || '继续推进上一次保存的故事。',
|
||||
coverImageSrc: row.coverImageSrc || null,
|
||||
lastPlayedAt: row.savedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProfileSaveArchiveMeta(
|
||||
snapshot: SavedSnapshot,
|
||||
): ProfileSaveArchiveMeta | null {
|
||||
const worldMeta = resolveProfileWorldSnapshotMeta(snapshot);
|
||||
if (!worldMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gameState = asRecord(snapshot.gameState);
|
||||
const continueGameDigest = readString(
|
||||
asRecord(gameState?.storyEngineMemory)?.continueGameDigest,
|
||||
);
|
||||
const currentStoryText = readSavedStoryText(snapshot.currentStory);
|
||||
const customWorldProfile = asRecord(gameState?.customWorldProfile);
|
||||
|
||||
if (customWorldProfile) {
|
||||
const profileId = readString(customWorldProfile.id) || 'custom-world';
|
||||
const metadata = extractCustomWorldLibraryMetadata(
|
||||
normalizeStoredProfile(profileId, customWorldProfile),
|
||||
);
|
||||
|
||||
return {
|
||||
worldKey: worldMeta.worldKey,
|
||||
ownerUserId: worldMeta.ownerUserId,
|
||||
profileId: worldMeta.profileId,
|
||||
worldType: worldMeta.worldType,
|
||||
worldName: worldMeta.worldTitle || metadata.worldName || '自定义世界',
|
||||
subtitle: metadata.subtitle || worldMeta.worldSubtitle || '',
|
||||
summaryText:
|
||||
continueGameDigest ||
|
||||
currentStoryText ||
|
||||
metadata.summaryText ||
|
||||
worldMeta.worldSubtitle ||
|
||||
'继续推进上一次保存的故事。',
|
||||
coverImageSrc: metadata.coverImageSrc,
|
||||
};
|
||||
}
|
||||
|
||||
const currentScenePreset = asRecord(gameState?.currentScenePreset);
|
||||
|
||||
return {
|
||||
worldKey: worldMeta.worldKey,
|
||||
ownerUserId: worldMeta.ownerUserId,
|
||||
profileId: worldMeta.profileId,
|
||||
worldType: worldMeta.worldType,
|
||||
worldName: worldMeta.worldTitle || '未命名游戏',
|
||||
subtitle: worldMeta.worldSubtitle || '',
|
||||
summaryText:
|
||||
continueGameDigest ||
|
||||
currentStoryText ||
|
||||
worldMeta.worldSubtitle ||
|
||||
'继续推进上一次保存的故事。',
|
||||
coverImageSrc: readString(currentScenePreset?.imageSrc) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
@@ -663,6 +779,29 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findProfileSaveArchive(userId: string, worldKey: string) {
|
||||
const result = await this.db.query<ProfileSaveArchiveRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_name AS "worldName",
|
||||
world_subtitle AS subtitle,
|
||||
summary_text AS "summaryText",
|
||||
cover_image_src AS "coverImageSrc",
|
||||
saved_at AS "savedAt",
|
||||
bottom_tab AS "bottomTab",
|
||||
game_state_json AS "gameState",
|
||||
current_story_json AS "currentStory"
|
||||
FROM profile_save_archives
|
||||
WHERE user_id = $1
|
||||
AND world_key = $2`,
|
||||
[userId, worldKey],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async upsertProfileDashboardState(
|
||||
userId: string,
|
||||
state: {
|
||||
@@ -686,6 +825,49 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
);
|
||||
}
|
||||
|
||||
private async upsertCurrentSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`INSERT INTO save_snapshots (
|
||||
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
version = EXCLUDED.version,
|
||||
saved_at = EXCLUDED.saved_at,
|
||||
bottom_tab = EXCLUDED.bottom_tab,
|
||||
game_state_json = EXCLUDED.game_state_json,
|
||||
current_story_json = EXCLUDED.current_story_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING version,
|
||||
saved_at AS "savedAt",
|
||||
game_state_json AS "gameState",
|
||||
bottom_tab AS "bottomTab",
|
||||
current_story_json AS "currentStory"`,
|
||||
[
|
||||
userId,
|
||||
snapshot.version,
|
||||
snapshot.savedAt,
|
||||
snapshot.bottomTab,
|
||||
snapshot.gameState,
|
||||
snapshot.currentStory,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
return {
|
||||
version: row.version,
|
||||
savedAt: row.savedAt,
|
||||
gameState: row.gameState,
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
}
|
||||
|
||||
private async syncProfileDashboardFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
@@ -788,6 +970,67 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
});
|
||||
}
|
||||
|
||||
private async syncProfileSaveArchiveFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
) {
|
||||
const archiveMeta = resolveProfileSaveArchiveMeta(snapshot);
|
||||
if (!archiveMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncedAt = snapshot.savedAt || new Date().toISOString();
|
||||
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_save_archives (
|
||||
user_id,
|
||||
world_key,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_type,
|
||||
world_name,
|
||||
world_subtitle,
|
||||
summary_text,
|
||||
cover_image_src,
|
||||
saved_at,
|
||||
bottom_tab,
|
||||
game_state_json,
|
||||
current_story_json,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (user_id, world_key) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
world_type = EXCLUDED.world_type,
|
||||
world_name = EXCLUDED.world_name,
|
||||
world_subtitle = EXCLUDED.world_subtitle,
|
||||
summary_text = EXCLUDED.summary_text,
|
||||
cover_image_src = EXCLUDED.cover_image_src,
|
||||
saved_at = EXCLUDED.saved_at,
|
||||
bottom_tab = EXCLUDED.bottom_tab,
|
||||
game_state_json = EXCLUDED.game_state_json,
|
||||
current_story_json = EXCLUDED.current_story_json,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[
|
||||
userId,
|
||||
archiveMeta.worldKey,
|
||||
archiveMeta.ownerUserId,
|
||||
archiveMeta.profileId,
|
||||
archiveMeta.worldType,
|
||||
archiveMeta.worldName,
|
||||
archiveMeta.subtitle,
|
||||
archiveMeta.summaryText,
|
||||
archiveMeta.coverImageSrc,
|
||||
syncedAt,
|
||||
snapshot.bottomTab,
|
||||
snapshot.gameState,
|
||||
snapshot.currentStory,
|
||||
syncedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private async syncCustomWorldProfileFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
@@ -883,45 +1126,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
bottomTab: payload.bottomTab,
|
||||
currentStory: payload.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`INSERT INTO save_snapshots (
|
||||
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
version = EXCLUDED.version,
|
||||
saved_at = EXCLUDED.saved_at,
|
||||
bottom_tab = EXCLUDED.bottom_tab,
|
||||
game_state_json = EXCLUDED.game_state_json,
|
||||
current_story_json = EXCLUDED.current_story_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING version,
|
||||
saved_at AS "savedAt",
|
||||
game_state_json AS "gameState",
|
||||
bottom_tab AS "bottomTab",
|
||||
current_story_json AS "currentStory"`,
|
||||
[
|
||||
userId,
|
||||
snapshot.version,
|
||||
snapshot.savedAt,
|
||||
snapshot.bottomTab,
|
||||
snapshot.gameState,
|
||||
snapshot.currentStory,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
const persistedSnapshot = {
|
||||
version: row.version,
|
||||
savedAt: row.savedAt,
|
||||
gameState: row.gameState,
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot);
|
||||
|
||||
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
|
||||
await this.syncProfileSaveArchiveFromSnapshot(userId, persistedSnapshot);
|
||||
await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot);
|
||||
|
||||
return persistedSnapshot;
|
||||
@@ -993,6 +1201,50 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
} satisfies ProfilePlayStatsResponse;
|
||||
}
|
||||
|
||||
async listProfileSaveArchives(userId: string) {
|
||||
const result = await this.db.query<ProfileSaveArchiveRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_name AS "worldName",
|
||||
world_subtitle AS subtitle,
|
||||
summary_text AS "summaryText",
|
||||
cover_image_src AS "coverImageSrc",
|
||||
saved_at AS "savedAt",
|
||||
bottom_tab AS "bottomTab",
|
||||
game_state_json AS "gameState",
|
||||
current_story_json AS "currentStory"
|
||||
FROM profile_save_archives
|
||||
WHERE user_id = $1
|
||||
ORDER BY saved_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => toProfileSaveArchiveSummary(row));
|
||||
}
|
||||
|
||||
async resumeProfileSaveArchive(userId: string, worldKey: string) {
|
||||
const archive = await this.findProfileSaveArchive(userId, worldKey);
|
||||
if (!archive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
savedAt: archive.savedAt,
|
||||
gameState: archive.gameState,
|
||||
bottomTab: archive.bottomTab,
|
||||
currentStory: archive.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot);
|
||||
|
||||
return {
|
||||
entry: toProfileSaveArchiveSummary(archive),
|
||||
snapshot: persistedSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteSnapshot(userId: string) {
|
||||
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [
|
||||
userId,
|
||||
@@ -1001,9 +1253,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
|
||||
async getSettings(userId: string) {
|
||||
const result = await this.db.query<SettingsRow>(
|
||||
`SELECT music_volume AS "musicVolume"
|
||||
FROM runtime_settings
|
||||
WHERE user_id = $1`,
|
||||
`SELECT music_volume AS "musicVolume",
|
||||
platform_theme AS "platformTheme"
|
||||
FROM runtime_settings
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
@@ -1013,26 +1266,41 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
typeof row?.musicVolume === 'number'
|
||||
? row.musicVolume
|
||||
: DEFAULT_MUSIC_VOLUME,
|
||||
platformTheme:
|
||||
row?.platformTheme === 'dark'
|
||||
? 'dark'
|
||||
: DEFAULT_PLATFORM_THEME,
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
async putSettings(userId: string, settings: RuntimeSettings) {
|
||||
const nextSettings = {
|
||||
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
|
||||
platformTheme:
|
||||
settings.platformTheme === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME,
|
||||
} satisfies RuntimeSettings;
|
||||
|
||||
const result = await this.db.query<SettingsRow>(
|
||||
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`INSERT INTO runtime_settings (user_id, music_volume, platform_theme, updated_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
music_volume = EXCLUDED.music_volume,
|
||||
platform_theme = EXCLUDED.platform_theme,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING music_volume AS "musicVolume"`,
|
||||
[userId, nextSettings.musicVolume, new Date().toISOString()],
|
||||
RETURNING music_volume AS "musicVolume",
|
||||
platform_theme AS "platformTheme"`,
|
||||
[
|
||||
userId,
|
||||
nextSettings.musicVolume,
|
||||
nextSettings.platformTheme,
|
||||
new Date().toISOString(),
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume,
|
||||
platformTheme:
|
||||
result.rows[0]?.platformTheme ?? nextSettings.platformTheme,
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user