// LifeTown Online — JSON-file persistence for player profiles. import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { START_MONEY, HOUSES } from '../shared/data.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const SAVE_DIR = path.join(__dirname, '..', 'saves'); fs.mkdirSync(SAVE_DIR, { recursive: true }); function sanitizeName(name) { return String(name || '').trim().replace(/[^A-Za-z0-9_\- ]/g, '').slice(0, 20); } export function newProfile(name, appearance) { const safe = sanitizeName(name); return { name: safe, token: crypto.randomBytes(16).toString('hex'), createdAt: Date.now(), appearance, // character creator output (client-defined schema) money: START_MONEY, level: 1, xp: 0, // player level job: null, // { id, rank, xp } workLastAt: 0, houseTier: 'studio', // owned house tier id housesOwned: ['studio'], homeLayout: [], // [{itemId,x,z,rot}] inventory: [], // [{itemId}] purchased, not yet placed relationships: {}, // npcId|playerName -> {f,r,status} achievements: [], stats: { shifts: 0, dances: 0, giftsSent: 0 }, needs: null, // client-simulated; stored for continuity moodlets: [], }; } export function loadProfile(name) { const safe = sanitizeName(name); const file = path.join(SAVE_DIR, `${safe.toLowerCase()}.json`); try { if (!fs.existsSync(file)) return null; const raw = JSON.parse(fs.readFileSync(file, 'utf8')); if (!raw || raw.name !== safe) return null; // migrate defaults const base = newProfile(safe, raw.appearance || defaultAppearance()); const merged = { ...base, ...raw, name: safe }; if (!Array.isArray(merged.homeLayout)) merged.homeLayout = []; if (!Array.isArray(merged.inventory)) merged.inventory = []; if (!Array.isArray(merged.achievements)) merged.achievements = []; if (typeof merged.relationships !== 'object' || !merged.relationships) merged.relationships = {}; if (typeof merged.stats !== 'object' || !merged.stats) merged.stats = { shifts: 0, dances: 0, giftsSent: 0 }; return merged; } catch { return null; } } export function saveProfile(profile) { const file = path.join(SAVE_DIR, `${profile.name.toLowerCase()}.json`); const tmp = file + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(profile)); fs.renameSync(tmp, file); } export function defaultAppearance() { return { bodyType: 'average', height: 1, skin: '#eab98f', faceShape: 'round', hair: 'short', hairColor: '#4a2f1d', eyes: 'round', eyeColor: '#3a5a40', brows: 'natural', nose: 'button', mouth: 'smile', outfit: { top: 'tee', bottom: 'jeans', dress: null, shoes: 'sneakers', accessory: 'none', topColor: '#4f8fd9', bottomColor: '#33415c' }, personality: ['Friendly'], voice: 'warm', age: 25, }; } export function findProfileByToken(token) { if (!token || typeof token !== 'string') return null; for (const f of fs.readdirSync(SAVE_DIR)) { if (!f.endsWith('.json')) continue; try { const p = JSON.parse(fs.readFileSync(path.join(SAVE_DIR, f), 'utf8')); if (p.token === token) return p; } catch { /* skip corrupt */ } } return null; } export function listProfileNames() { try { return fs.readdirSync(SAVE_DIR).filter(f => f.endsWith('.json')).map(f => { try { return JSON.parse(fs.readFileSync(path.join(SAVE_DIR, f), 'utf8')).name; } catch { return null; } }).filter(Boolean); } catch { return []; } } export { sanitizeName };