1
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user