feat: migrate runtime backend to node server

This commit is contained in:
victo
2026-04-08 16:41:29 +08:00
parent 9d2fc9e4b8
commit a83841ff2d
70 changed files with 8239 additions and 1561 deletions

View File

@@ -0,0 +1,182 @@
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 = {
version: number;
savedAt: string;
gameState: unknown;
bottomTab: string;
currentStory: unknown;
};
export type RuntimeSettings = {
musicVolume: number;
};
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
function toJson(value: unknown) {
return JSON.stringify(value ?? null);
}
export class RuntimeRepository {
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;
if (!row) {
return null;
}
return {
version: row.version,
savedAt: row.saved_at,
gameState: parseJson(row.game_state_json),
bottomTab: row.bottom_tab,
currentStory: parseJson(row.current_story_json),
} satisfies SavedSnapshot;
}
putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
const snapshot = {
version: SAVE_SNAPSHOT_VERSION,
savedAt: payload.savedAt,
gameState: payload.gameState,
bottomTab: payload.bottomTab,
currentStory: payload.currentStory,
} satisfies SavedSnapshot;
const now = new Date().toISOString();
this.db
.prepare(
`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(
userId,
snapshot.version,
snapshot.savedAt,
snapshot.bottomTab,
toJson(snapshot.gameState),
toJson(snapshot.currentStory),
now,
);
return snapshot;
}
deleteSnapshot(userId: string) {
this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(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;
return {
musicVolume:
typeof row?.music_volume === 'number'
? row.music_volume
: DEFAULT_MUSIC_VOLUME,
} satisfies RuntimeSettings;
}
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());
return nextSettings;
}
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 }>;
return rows.map((row) => parseJson<Record<string, unknown>>(row.payload_json));
}
upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
) {
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());
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);
return this.listCustomWorldProfiles(userId);
}
}