Spaces:
Running
Running
| /** | |
| * JSON file database — single source of truth for server-side state. | |
| * | |
| * All mutations are atomic (write entire file via fs.rename) so concurrent | |
| * requests won't produce half-written files. On HF Spaces the /data directory | |
| * persists across container restarts. | |
| * | |
| * NEVER import this from client code — it uses Node.js fs and has the | |
| * server-side access_token. Client code goes through /api/db instead. | |
| */ | |
| import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs"; | |
| import { resolve } from "node:path"; | |
| import { tmpdir } from "node:os"; | |
| // --------------------------------------------------------------------------- | |
| // Types | |
| // --------------------------------------------------------------------------- | |
| export interface PlayerRecord { | |
| id: string; | |
| display_name: string; | |
| anonymous_id: string | null; | |
| elo_rating: number; | |
| games_played: number; | |
| star_coins: number; | |
| claim_code: string | null; | |
| spectrum_position: number | null; | |
| created_at: string; | |
| } | |
| export interface GameSessionRecord { | |
| id: string; | |
| player_id: string; | |
| mode: string; | |
| score: number; | |
| accuracy: number; | |
| streak_max: number; | |
| duration_ms: number; | |
| questions_answered: number; | |
| questions_correct: number; | |
| created_at: string; | |
| } | |
| export interface LeaderboardEntryRecord { | |
| id: string; | |
| player_id: string; | |
| player_name: string; | |
| mode: string; | |
| score: number; | |
| accuracy: number; | |
| season_id: string | null; | |
| created_at: string; | |
| } | |
| export interface PlayerAchievementRecord { | |
| id: string; | |
| player_id: string; | |
| achievement_id: string; | |
| progress: number; | |
| unlocked: boolean; | |
| unlocked_at: string | null; | |
| } | |
| export interface PlayerCosmeticRecord { | |
| id: string; | |
| player_id: string; | |
| cosmetic_id: string; | |
| purchased_at: string; | |
| } | |
| export interface PlayerEquippedRecord { | |
| id: string; | |
| player_id: string; | |
| category: string; | |
| cosmetic_id: string; | |
| } | |
| export interface MatchRecord { | |
| id: string; | |
| player1_id: string; | |
| player2_id: string; | |
| player1_score: number; | |
| player2_score: number; | |
| winner_id: string | null; | |
| elo_change: number; | |
| question_ids: string[]; | |
| created_at: string; | |
| } | |
| export interface DebateRecord { | |
| id: string; | |
| topic_id: string; | |
| player1_id: string | null; | |
| player2_id: string | null; | |
| duration_minutes: number; | |
| rounds: number; | |
| winner_id: string | null; | |
| player1_score: number | null; | |
| player2_score: number | null; | |
| verdict: string | null; | |
| elo_change: number | null; | |
| is_practice: boolean | null; | |
| created_at: string; | |
| } | |
| export interface DebateRoundRecord { | |
| id: string; | |
| debate_id: string; | |
| round_number: number; | |
| player1_argument: string | null; | |
| player2_argument: string | null; | |
| created_at: string; | |
| } | |
| export interface DbData { | |
| players: PlayerRecord[]; | |
| game_sessions: GameSessionRecord[]; | |
| leaderboard_entries: LeaderboardEntryRecord[]; | |
| player_achievements: PlayerAchievementRecord[]; | |
| player_cosmetics: PlayerCosmeticRecord[]; | |
| player_equipped: PlayerEquippedRecord[]; | |
| matches: MatchRecord[]; | |
| debates: DebateRecord[]; | |
| debate_rounds: DebateRoundRecord[]; | |
| // Store the seed state for deterministic IDs | |
| _nextId: number; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // File paths | |
| // --------------------------------------------------------------------------- | |
| function getDbDir(): string { | |
| // HF Spaces /data persists; fall back to tmp during dev | |
| return process.env.HF_SPACE_STORAGE_PATH | |
| ? resolve(process.env.HF_SPACE_STORAGE_PATH, "triviaverse") | |
| : process.env.DATA_DIR | |
| ? process.env.DATA_DIR | |
| : resolve(process.cwd(), ".data"); | |
| } | |
| const DB_PATH = () => resolve(getDbDir(), "db.json"); | |
| // --------------------------------------------------------------------------- | |
| // Initial data | |
| // --------------------------------------------------------------------------- | |
| const EMPTY_DB: DbData = { | |
| players: [], | |
| game_sessions: [], | |
| leaderboard_entries: [], | |
| player_achievements: [], | |
| player_cosmetics: [], | |
| player_equipped: [], | |
| matches: [], | |
| debates: [], | |
| debate_rounds: [], | |
| _nextId: 1, | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Atomic read / write | |
| // --------------------------------------------------------------------------- | |
| let _cache: DbData | null = null; | |
| export function readDb(): DbData { | |
| if (_cache) return _cache; | |
| const path = DB_PATH(); | |
| if (!existsSync(path)) { | |
| // Ensure directory exists | |
| const dir = getDbDir(); | |
| if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); | |
| writeSync(EMPTY_DB); | |
| _cache = { ...EMPTY_DB }; | |
| return _cache; | |
| } | |
| try { | |
| const raw = readFileSync(path, "utf-8"); | |
| _cache = JSON.parse(raw) as DbData; | |
| return _cache; | |
| } catch { | |
| _cache = { ...EMPTY_DB }; | |
| return _cache; | |
| } | |
| } | |
| function writeSync(data: DbData): void { | |
| const path = DB_PATH(); | |
| const tmp = resolve(tmpdir(), `db-${Date.now()}.json`); | |
| writeFileSync(tmp, JSON.stringify(data, null, 1), "utf-8"); | |
| renameSync(tmp, path); | |
| } | |
| export function writeDb(data: DbData): void { | |
| _cache = data; | |
| writeSync(data); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // ID generation | |
| // --------------------------------------------------------------------------- | |
| export function nextId(): string { | |
| const db = readDb(); | |
| const id = String(db._nextId); | |
| db._nextId++; | |
| writeDb(db); | |
| return id; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Claim code generation | |
| // --------------------------------------------------------------------------- | |
| const CLAIM_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; | |
| export function generateClaimCode(): string { | |
| return Array.from( | |
| { length: 6 }, | |
| () => CLAIM_CHARSET[Math.floor(Math.random() * CLAIM_CHARSET.length)] | |
| ).join(""); | |
| } | |
| export function generateUniqueClaimCode(): string { | |
| const db = readDb(); | |
| for (let attempt = 0; attempt < 100; attempt++) { | |
| const code = generateClaimCode(); | |
| if (!db.players.some((p) => p.claim_code === code)) return code; | |
| } | |
| return `X${Date.now().toString(36).toUpperCase().slice(0, 5)}`; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Read helpers | |
| // --------------------------------------------------------------------------- | |
| export function getTimestamp(): string { | |
| return new Date().toISOString(); | |
| } | |