// Auth & Profile Management System — Server-backed (SQLite via /api/auth/) // Falls back to localStorage if server is unavailable. window.PvZAuth = (function () { const CURRENT_USER_KEY = 'pvz_current_user'; const TOKEN_KEY = 'pvz_token'; const LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache const API_BASE = '/api/auth'; /* ─── Session Token Helpers ─── */ function getToken() { try { return localStorage.getItem(TOKEN_KEY) || null; } catch (_) { return null; } } function setToken(token) { try { if (token) localStorage.setItem(TOKEN_KEY, token); else localStorage.removeItem(TOKEN_KEY); } catch (_) {} } function clearToken() { try { localStorage.removeItem(TOKEN_KEY); } catch (_) {} } /* ─── Password hashing (djb2 for lightweight fast client hashing) ─── */ function hashPassword(str) { if (!str) return ''; let h = 0; for (let i = 0; i < str.length; i++) { h = ((h << 5) - h) + str.charCodeAt(i); h |= 0; } return h.toString(16); } /* ─── Validation Helpers ─── */ function validateUsername(username) { const u = (username || '').trim(); if (!u) return { valid: false, message: 'Введите имя пользователя' }; if (u.length < 3) return { valid: false, message: 'Логин должен содержать минимум 3 символа' }; if (u.length > 20) return { valid: false, message: 'Логин не должен превышать 20 символов' }; if (!/^[a-zA-Zа-яА-ЯёЁ0-9_-]+$/.test(u)) { return { valid: false, message: 'Логин может содержать только буквы, цифры, "-" и "_"' }; } const reserved = ['гость', 'guest', 'admin', 'null', 'undefined', 'system', 'anonymous']; if (reserved.includes(u.toLowerCase())) { return { valid: false, message: 'Это имя пользователя зарезервировано' }; } return { valid: true, username: u }; } function validatePassword(password) { const p = password || ''; if (!p) return { valid: false, message: 'Введите пароль' }; if (p.length < 4) return { valid: false, message: 'Пароль должен содержать не менее 4 символов' }; return { valid: true, password: p }; } /* ─── Notification Toast Helper ─── */ function showNotification(title, subtitle = '', icon = '💾') { try { let toast = document.getElementById('achievement-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'achievement-toast'; toast.className = 'achievement-toast'; document.body.appendChild(toast); } toast.innerHTML = `
${icon}
${subtitle || 'УВЕДОМЛЕНИЕ'}
${title}
`; toast.classList.add('visible'); setTimeout(() => { if (toast) toast.classList.remove('visible'); }, 3500); } catch (_) {} } /* ─── Default blank profile ─── */ function getDefaultData() { return { coins: 150, level: 1, maxUnlockedLevel: 1, highScore: 0, theme: 'classic', upgrades: { extraSlot: false, hasRake: false }, unlockedPlants: ['sunflower', 'peashooter', 'wallnut'], selectedDeck: ['sunflower', 'peashooter', 'wallnut'], materials: { bioHumus: 10, scrapMetal: 5, sunEssence: 20, crystals: 0 }, plantLevels: { peashooter: 1, sunflower: 1, wallnut: 1, potatomine: 1, repeater: 1, snowpea: 1, chomper: 1 }, perks: { magnetism: 0, startSun: 0, fastCool: 0, hardyRoots: 0, luckyDrop: 0 }, zenGarden: { pots: [ { id: 0, plantType: 'sunflower', stage: 2, thirstTimer: 0, needsFertilizer: false, happyTimer: 60, coinTimer: 5 }, { id: 1, plantType: 'peashooter', stage: 1, thirstTimer: 0, needsFertilizer: false, happyTimer: 45, coinTimer: 8 }, { id: 2, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 }, { id: 3, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 }, { id: 4, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 }, { id: 5, plantType: null, stage: 0, thirstTimer: 0, needsFertilizer: false, happyTimer: 0, coinTimer: 20 } ], fertilizerCount: 5, chocolateCount: 2, snail: { x: 100, targetX: 300, dir: 1, isAwake: true, sleepTimer: 120, boostTimer: 0 }, lastVisit: Date.now() }, treeOfWisdom: { height: 0, cheats: {} }, achievements: {} }; } /* ─── In-memory current user data ─── */ let _cachedData = null; function localSave(data) { try { localStorage.setItem(LOCAL_DATA_KEY, JSON.stringify(data)); } catch (_) {} } function localLoad() { try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; } } /* ─── Non-blocking server call with Bearer Token Authorization ─── */ async function apiPost(endpoint, body = {}, keepalive = false) { try { const headers = { 'Content-Type': 'application/json' }; const token = getToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } // Attach token in JSON body as well for maximum compatibility const payload = (typeof body === 'object' && body !== null && token) ? { token, ...body } : body; const fetchOptions = { method: 'POST', headers, body: JSON.stringify(payload) }; if (keepalive && typeof fetch === 'function') { fetchOptions.keepalive = true; } const resp = await fetch(`${API_BASE}/${endpoint}`, fetchOptions); if (resp.status === 401) { // Clear invalid or expired session token clearToken(); const json = await resp.json().catch(() => ({})); return { success: false, status: 401, message: json.message || 'Сессия истекла или недействительна' }; } const json = await resp.json(); return json; } catch (e) { return { success: false, message: 'Сервер авторизации недоступен' }; } } /* ─── Debounced save (prevents save storms on rapid updates) ─── */ let _saveTimer = null; function scheduleSave() { if (_saveTimer) clearTimeout(_saveTimer); _saveTimer = setTimeout(() => { const user = localStorage.getItem(CURRENT_USER_KEY); if (!user || user === 'Гость' || !_cachedData) return; apiPost('save', { username: user, data: _cachedData }); }, 1500); } function mergeUserData(serverData) { const def = getDefaultData(); if (!serverData) return def; return { ...def, ...serverData, upgrades: { ...def.upgrades, ...(serverData.upgrades || {}) }, unlockedPlants: serverData.unlockedPlants || def.unlockedPlants, selectedDeck: serverData.selectedDeck || def.selectedDeck, materials: { ...def.materials, ...(serverData.materials || {}) }, plantLevels: { ...def.plantLevels, ...(serverData.plantLevels || {}) }, perks: { ...def.perks, ...(serverData.perks || {}) }, zenGarden: serverData.zenGarden ? { ...def.zenGarden, ...serverData.zenGarden, pots: serverData.zenGarden.pots || def.zenGarden.pots, snail: { ...def.zenGarden.snail, ...(serverData.zenGarden.snail || {}) } } : def.zenGarden, treeOfWisdom: serverData.treeOfWisdom || def.treeOfWisdom, achievements: serverData.achievements || {} }; } const authInstance = { /* ── Public API ── */ getCurrentUser() { return localStorage.getItem(CURRENT_USER_KEY) || 'Гость'; }, isGuest() { const u = localStorage.getItem(CURRENT_USER_KEY); return !u || u === 'Гость'; }, getToken, setToken, clearToken, getDefaultData, validateUsername, validatePassword, apiPost, scheduleSave, showNotification, /** * User Login */ async login(username, password, callback = null) { const userVal = validateUsername(username); if (!userVal.valid) { if (typeof callback === 'function') callback(false, null, userVal.message); return { success: false, message: userVal.message }; } const passVal = validatePassword(password); if (!passVal.valid) { if (typeof callback === 'function') callback(false, null, passVal.message); return { success: false, message: passVal.message }; } const cleanUser = userVal.username; const pwdHash = hashPassword(passVal.password); const res = await apiPost('login', { username: cleanUser, passwordHash: pwdHash }); if (res && res.success && res.data) { localStorage.setItem(CURRENT_USER_KEY, cleanUser); if (res.token) { setToken(res.token); } _cachedData = mergeUserData(res.data); localSave(_cachedData); if (window.GameEngine && window._pvzEngine) { window._pvzEngine.loadUserData(); } if (typeof callback === 'function') callback(true, _cachedData, null); return { success: true, username: cleanUser, token: res.token, data: _cachedData }; } else { const errorMsg = (res && res.message) ? res.message : 'Неверный логин или пароль'; if (typeof callback === 'function') callback(false, null, errorMsg); return { success: false, message: errorMsg }; } }, /** * User Registration */ async register(username, password, callback = null) { const userVal = validateUsername(username); if (!userVal.valid) { if (typeof callback === 'function') callback(false, null, userVal.message); return { success: false, message: userVal.message }; } const passVal = validatePassword(password); if (!passVal.valid) { if (typeof callback === 'function') callback(false, null, passVal.message); return { success: false, message: passVal.message }; } const cleanUser = userVal.username; const pwdHash = hashPassword(passVal.password); const initData = _cachedData || getDefaultData(); const res = await apiPost('register', { username: cleanUser, passwordHash: pwdHash, initialData: initData }); if (res && res.success) { localStorage.setItem(CURRENT_USER_KEY, cleanUser); if (res.token) { setToken(res.token); } _cachedData = mergeUserData(res.data || initData); localSave(_cachedData); if (window.GameEngine && window._pvzEngine) { window._pvzEngine.loadUserData(); } if (typeof callback === 'function') callback(true, _cachedData, null); return { success: true, username: cleanUser, token: res.token, data: _cachedData }; } else { const errorMsg = (res && res.message) ? res.message : 'Ошибка при регистрации'; if (typeof callback === 'function') callback(false, null, errorMsg); return { success: false, message: errorMsg }; } }, loginAsGuest() { clearToken(); localStorage.setItem(CURRENT_USER_KEY, 'Гость'); return { success: true, user: 'Гость' }; }, logout() { if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } const user = this.getCurrentUser(); const token = getToken(); if (user !== 'Гость' && _cachedData) { apiPost('save', { username: user, data: _cachedData }); } if (token) { apiPost('logout', { token }); } this.loginAsGuest(); }, getUserData() { if (_cachedData) return _cachedData; const local = localLoad(); if (local) { _cachedData = mergeUserData(local); return _cachedData; } _cachedData = getDefaultData(); return _cachedData; }, saveUserData(data) { _cachedData = _cachedData ? { ..._cachedData, ...data } : { ...getDefaultData(), ...data }; localSave(_cachedData); // instant local write for both guests and registered users if (!this.isGuest()) { scheduleSave(); // debounced server sync for logged-in accounts } }, /** Force immediate server save (call on game-over / victory / visibility hidden / unload) */ flushSave(keepalive = false) { if (this.isGuest() || !_cachedData) return; if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } apiPost('save', { username: this.getCurrentUser(), data: _cachedData }, keepalive); }, /** * Export all save data (Profile, levels, shop, garden, scores) to a JSON file */ exportSaveFile() { try { if (window._pvzEngine && typeof window._pvzEngine.saveUserData === 'function') { window._pvzEngine.saveUserData(); } const data = this.getUserData(); const username = this.getCurrentUser(); const isGuest = this.isGuest(); const highScores = { adventure: data.highScore || 0, survival: parseInt(localStorage.getItem('pvz_hs_survival') || '0', 10), bowling: parseInt(localStorage.getItem('pvz_hs_bowling') || '0', 10), izombie: parseInt(localStorage.getItem('pvz_hs_izombie') || '0', 10), rogue: parseInt(localStorage.getItem('pvz_hs_rogue') || '0', 10), boss: parseInt(localStorage.getItem('pvz_hs_boss') || '0', 10), bossrush: parseInt(localStorage.getItem('pvz_hs_bossrush') || '0', 10) }; const exportPayload = { schema: 'pvz_save_file', version: 2, exportedAt: new Date().toISOString(), username: isGuest ? 'Гость' : username, data: { ...data, highScores } }; const jsonStr = JSON.stringify(exportPayload, null, 2); const blob = new Blob([jsonStr], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const now = new Date(); const dateStr = now.toISOString().slice(0, 10); const safeUser = (username || 'guest').replace(/[^a-zA-Zа-яА-Я0-9_-]/g, '_'); const filename = `pvz_save_${safeUser}_${dateStr}.json`; const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); showNotification('Файл сохранения скачан!', filename, '💾'); if (window.PvZAudio && window.PvZAudio.playCoin) window.PvZAudio.playCoin(); return { success: true, filename }; } catch (err) { console.error('Export save error:', err); showNotification('Ошибка экспорта', err.message || 'Сбой', '❌'); return { success: false, message: err.message }; } }, /** * Import save data from JSON file with validation & UI refresh */ async importSaveFile(file) { if (!file) { showNotification('Файл не выбран', 'Выберите .json файл', '⚠️'); return { success: false, message: 'Файл не выбран' }; } try { const text = await file.text(); let parsed; try { parsed = JSON.parse(text); } catch (e) { showNotification('Некорректный JSON', 'Файл поврежден или не является JSON', '❌'); return { success: false, message: 'Некорректный JSON формат файла' }; } if (!parsed || typeof parsed !== 'object') { showNotification('Неверный формат', 'Файл пуст или некорректен', '❌'); return { success: false, message: 'Неверная структура данных' }; } // Support both wrapped export payload and raw data format let rawData = (parsed.schema === 'pvz_save_file' && parsed.data) ? parsed.data : (parsed.data || parsed); if (typeof rawData !== 'object' || rawData === null) { showNotification('Ошибка в данных', 'Внутри файла нет объекта сохранений', '❌'); return { success: false, message: 'Некорректный объект данных' }; } // Field sanitization & validation if (rawData.coins !== undefined && (typeof rawData.coins !== 'number' || isNaN(rawData.coins) || rawData.coins < 0)) { rawData.coins = 150; } if (rawData.level !== undefined && (typeof rawData.level !== 'number' || isNaN(rawData.level) || rawData.level < 1)) { rawData.level = 1; } if (rawData.maxUnlockedLevel !== undefined && (typeof rawData.maxUnlockedLevel !== 'number' || isNaN(rawData.maxUnlockedLevel) || rawData.maxUnlockedLevel < 1)) { rawData.maxUnlockedLevel = rawData.level || 1; } // Merge with defaults to guarantee all required properties exist _cachedData = mergeUserData(rawData); localSave(_cachedData); // Restore individual high score keys if present if (rawData.highScores && typeof rawData.highScores === 'object') { if (rawData.highScores.survival !== undefined) localStorage.setItem('pvz_hs_survival', String(rawData.highScores.survival)); if (rawData.highScores.bowling !== undefined) localStorage.setItem('pvz_hs_bowling', String(rawData.highScores.bowling)); if (rawData.highScores.izombie !== undefined) localStorage.setItem('pvz_hs_izombie', String(rawData.highScores.izombie)); if (rawData.highScores.rogue !== undefined) localStorage.setItem('pvz_hs_rogue', String(rawData.highScores.rogue)); if (rawData.highScores.boss !== undefined) localStorage.setItem('pvz_hs_boss', String(rawData.highScores.boss)); if (rawData.highScores.bossrush !== undefined) localStorage.setItem('pvz_hs_bossrush', String(rawData.highScores.bossrush)); } // Sync to server if user is logged in if (!this.isGuest()) { this.flushSave(); } // Reload state into game engine if (window._pvzEngine) { window._pvzEngine.loadUserData(); window._pvzEngine.updateProfileUI(); window._pvzEngine.updateRecordDisplay(); window._pvzEngine.updateShopUI(); } showNotification('Сохранение загружено!', `Баланс: ${_cachedData.coins} 💰, Уровень ${_cachedData.level}`, '✅'); if (window.PvZAudio && window.PvZAudio.playCoin) window.PvZAudio.playCoin(); return { success: true, data: _cachedData }; } catch (err) { console.error('Import save error:', err); showNotification('Ошибка импорта', err.message || 'Не удалось прочитать файл', '❌'); return { success: false, message: err.message }; } } }; /* ─── Auto-save on page exit / tab switch ─── */ if (typeof window !== 'undefined' && typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { if (window._pvzEngine && typeof window._pvzEngine.saveUserData === 'function') { window._pvzEngine.saveUserData(); } authInstance.flushSave(); } }); window.addEventListener('beforeunload', () => { if (window._pvzEngine && typeof window._pvzEngine.saveUserData === 'function') { window._pvzEngine.saveUserData(); } authInstance.flushSave(true); }); } return authInstance; })();