This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -1,10 +1,21 @@
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 { AppDatabase } from '../db.js';
const SAVE_SNAPSHOT_VERSION = 2;
const DEFAULT_MUSIC_VOLUME = 0.42;
const MAX_CUSTOM_WORLD_PROFILES = 12;
export type SavedSnapshot = {
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
type SnapshotRow = QueryResultRow & {
version: number;
savedAt: string;
gameState: unknown;
@@ -12,37 +23,53 @@ export type SavedSnapshot = {
currentStory: unknown;
};
export type RuntimeSettings = {
type SettingsRow = QueryResultRow & {
musicVolume: number;
};
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
type ProfileRow = QueryResultRow & {
payload: CustomWorldProfileRecord;
};
function toJson(value: unknown) {
return JSON.stringify(value ?? null);
}
export type RuntimeRepositoryPort = {
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
putSnapshot(
userId: string,
payload: Omit<SavedSnapshot, 'version'>,
): Promise<SavedSnapshot>;
deleteSnapshot(userId: string): Promise<void>;
getSettings(userId: string): Promise<RuntimeSettings>;
putSettings(
userId: string,
settings: RuntimeSettings,
): Promise<RuntimeSettings>;
listCustomWorldProfiles(userId: string): Promise<CustomWorldProfileRecord[]>;
upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
): Promise<CustomWorldProfileRecord[]>;
deleteCustomWorldProfile(
userId: string,
profileId: string,
): Promise<CustomWorldProfileRecord[]>;
};
export class RuntimeRepository {
export class RuntimeRepository implements RuntimeRepositoryPort {
constructor(private readonly db: AppDatabase) {}
getSnapshot(userId: string) {
const row = this.db
.prepare(
`SELECT version, saved_at, game_state_json, bottom_tab, current_story_json
FROM save_snapshots
WHERE user_id = ?`,
)
.get(userId) as
| {
version: number;
saved_at: string;
game_state_json: string;
bottom_tab: string;
current_story_json: string;
}
| undefined;
async getSnapshot(userId: string) {
const result = await this.db.query<SnapshotRow>(
`SELECT version,
saved_at AS "savedAt",
game_state_json AS "gameState",
bottom_tab AS "bottomTab",
current_story_json AS "currentStory"
FROM save_snapshots
WHERE user_id = $1`,
[userId],
);
const row = result.rows[0];
if (!row) {
return null;
@@ -50,14 +77,14 @@ export class RuntimeRepository {
return {
version: row.version,
savedAt: row.saved_at,
gameState: parseJson(row.game_state_json),
bottomTab: row.bottom_tab,
currentStory: parseJson(row.current_story_json),
savedAt: row.savedAt,
gameState: row.gameState,
bottomTab: row.bottomTab,
currentStory: row.currentStory,
} satisfies SavedSnapshot;
}
putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
async putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
const snapshot = {
version: SAVE_SNAPSHOT_VERSION,
savedAt: payload.savedAt,
@@ -67,115 +94,126 @@ export class RuntimeRepository {
} satisfies SavedSnapshot;
const now = new Date().toISOString();
this.db
.prepare(
`INSERT INTO save_snapshots (
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 (?, ?, ?, ?, ?, ?, ?)
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`,
)
.run(
) 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,
toJson(snapshot.gameState),
toJson(snapshot.currentStory),
snapshot.gameState,
snapshot.currentStory,
now,
);
],
);
return snapshot;
const row = result.rows[0];
return {
version: row.version,
savedAt: row.savedAt,
gameState: row.gameState,
bottomTab: row.bottomTab,
currentStory: row.currentStory,
} satisfies SavedSnapshot;
}
deleteSnapshot(userId: string) {
this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId);
async deleteSnapshot(userId: string) {
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [userId]);
}
getSettings(userId: string) {
const row = this.db
.prepare(
`SELECT music_volume
FROM runtime_settings
WHERE user_id = ?`,
)
.get(userId) as { music_volume: number } | undefined;
async getSettings(userId: string) {
const result = await this.db.query<SettingsRow>(
`SELECT music_volume AS "musicVolume"
FROM runtime_settings
WHERE user_id = $1`,
[userId],
);
const row = result.rows[0];
return {
musicVolume:
typeof row?.music_volume === 'number'
? row.music_volume
typeof row?.musicVolume === 'number'
? row.musicVolume
: DEFAULT_MUSIC_VOLUME,
} satisfies RuntimeSettings;
}
putSettings(userId: string, settings: RuntimeSettings) {
async putSettings(userId: string, settings: RuntimeSettings) {
const nextSettings = {
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
} satisfies RuntimeSettings;
this.db
.prepare(
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
music_volume = excluded.music_volume,
updated_at = excluded.updated_at`,
)
.run(userId, nextSettings.musicVolume, new Date().toISOString());
const result = await this.db.query<SettingsRow>(
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE SET
music_volume = EXCLUDED.music_volume,
updated_at = EXCLUDED.updated_at
RETURNING music_volume AS "musicVolume"`,
[userId, nextSettings.musicVolume, new Date().toISOString()],
);
return nextSettings;
return {
musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume,
} satisfies RuntimeSettings;
}
listCustomWorldProfiles(userId: string) {
const rows = this.db
.prepare(
`SELECT payload_json
FROM custom_world_profiles
WHERE user_id = ?
ORDER BY updated_at DESC
LIMIT ?`,
)
.all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>;
async listCustomWorldProfiles(userId: string) {
const result = await this.db.query<ProfileRow>(
`SELECT payload_json AS payload
FROM custom_world_profiles
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT $2`,
[userId, MAX_CUSTOM_WORLD_PROFILES],
);
return rows.map((row) => parseJson<Record<string, unknown>>(row.payload_json));
return result.rows.map((row: ProfileRow) => row.payload);
}
upsertCustomWorldProfile(
async upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
profile: CustomWorldProfileRecord,
) {
const payload = {
...profile,
id: profileId,
};
this.db
.prepare(
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at`,
)
.run(userId, profileId, JSON.stringify(payload), 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)
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()],
);
return this.listCustomWorldProfiles(userId);
}
deleteCustomWorldProfile(userId: string, profileId: string) {
this.db
.prepare(
`DELETE FROM custom_world_profiles
WHERE user_id = ? AND profile_id = ?`,
)
.run(userId, profileId);
async deleteCustomWorldProfile(userId: string, profileId: string) {
await this.db.query(
`DELETE FROM custom_world_profiles
WHERE user_id = $1 AND profile_id = $2`,
[userId, profileId],
);
return this.listCustomWorldProfiles(userId);
}