/** * 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(); }