// 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', stats: { zombiesKilled: 0, sunProduced: 0, plantsPlanted: 0, levelsCompleted: 0, coinsEarned: 0 }, 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; } } /* ─── Profile change event listeners ─── */ const _profileListeners = new Set(); function onProfileChange(fn) { if (typeof fn === 'function') _profileListeners.add(fn); } function offProfileChange(fn) { _profileListeners.delete(fn); } function notifyProfileChange(data) { _profileListeners.forEach(fn => { try { fn(data); } catch (e) { console.error('Profile listener error:', e); } }); try { if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('pvz:profile-updated', { detail: data })); } } catch (_) {} } /* ─── Non-blocking GET server call with Bearer Token Authorization ─── */ async function apiGet(endpoint) { try { const headers = {}; const token = getToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } const resp = await fetch(`${API_BASE}/${endpoint}`, { method: 'GET', headers }); if (resp.status === 401) { 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: 'Сервер авторизации недоступен' }; } } /* ─── 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; let _hasSyncedFromServer = false; function scheduleSave() { if (_saveTimer) clearTimeout(_saveTimer); _saveTimer = setTimeout(async () => { const user = localStorage.getItem(CURRENT_USER_KEY); if (!user || user === 'Гость' || !_cachedData) return; if (!_hasSyncedFromServer && getToken()) { await authInstance.fetchProfile(); } apiPost('save', { username: user, data: _cachedData }); }, 1500); } function mergeUserData(serverData, localData = null) { const def = getDefaultData(); if (!serverData && !localData) return def; const base = serverData || localData || def; const loc = localData || {}; // Combine unlocked plants uniquely const unlocked = Array.from(new Set([ ...def.unlockedPlants, ...(loc.unlockedPlants || []), ...(base.unlockedPlants || []) ])); // Combine selected deck const selDeck = (base.selectedDeck && base.selectedDeck.length > 0) ? base.selectedDeck : ((loc.selectedDeck && loc.selectedDeck.length > 0) ? loc.selectedDeck : def.selectedDeck); return { ...def, ...loc, ...base, coins: base.coins !== undefined ? base.coins : (loc.coins !== undefined ? loc.coins : def.coins), level: Math.max(base.level || 1, loc.level || 1), maxUnlockedLevel: Math.max(base.maxUnlockedLevel || 1, loc.maxUnlockedLevel || 1, base.level || 1, loc.level || 1), highScore: Math.max(base.highScore || 0, loc.highScore || 0), theme: base.theme || loc.theme || def.theme, stats: { ...def.stats, ...(loc.stats || {}), ...(base.stats || {}) }, upgrades: { ...def.upgrades, ...(loc.upgrades || {}), ...(base.upgrades || {}), unlockedPlants: unlocked, selectedDeck: selDeck }, unlockedPlants: unlocked, selectedDeck: selDeck, materials: { ...def.materials, ...(loc.materials || {}), ...(base.materials || {}) }, plantLevels: { ...def.plantLevels, ...(loc.plantLevels || {}), ...(base.plantLevels || {}) }, perks: { ...def.perks, ...(loc.perks || {}), ...(base.perks || {}) }, zenGarden: base.zenGarden ? { ...def.zenGarden, ...(loc.zenGarden || {}), ...base.zenGarden, pots: base.zenGarden.pots || (loc.zenGarden && loc.zenGarden.pots) || def.zenGarden.pots, snail: { ...def.zenGarden.snail, ...((loc.zenGarden && loc.zenGarden.snail) || {}), ...(base.zenGarden.snail || {}) } } : (loc.zenGarden || def.zenGarden), treeOfWisdom: base.treeOfWisdom || loc.treeOfWisdom || def.treeOfWisdom, achievements: { ...(loc.achievements || {}), ...(base.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, apiGet, apiPost, scheduleSave, showNotification, onProfileChange, offProfileChange, notifyProfileChange, /** * 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 window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI(); if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay(); if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI(); } notifyProfileChange(_cachedData); 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 window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI(); if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay(); if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI(); } notifyProfileChange(_cachedData); 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, 'Гость'); notifyProfileChange(this.getUserData()); 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(); }, /** * Strictly Server-Authoritative Profile Sync: * When user is logged in, fetches /api/auth/profile (with fallback to /api/users) * and applies it directly to the game state. */ async fetchProfile() { const token = getToken(); const user = this.getCurrentUser(); if (!token || user === 'Гость') return null; try { let profileData = null; // 1. Try dedicated profile endpoint const res = await apiGet('profile'); if (res && res.success && res.data) { profileData = res.data; } else if (res && res.status === 401) { clearToken(); return null; } // 2. Fallback to /api/users which is universally supported across backend versions if (!profileData) { try { const usersResp = await fetch('/api/users'); if (usersResp.ok) { const usersJson = await usersResp.json(); if (usersJson && usersJson.success && Array.isArray(usersJson.users)) { const matched = usersJson.users.find(u => u.username && u.username.toLowerCase() === user.toLowerCase()); if (matched) { profileData = matched; } } } } catch (_) {} } if (profileData) { _hasSyncedFromServer = true; _cachedData = mergeUserData(profileData); localSave(_cachedData); if (window._pvzEngine) { window._pvzEngine.loadUserData(); if (typeof window._pvzEngine.updateProfileUI === 'function') window._pvzEngine.updateProfileUI(); if (typeof window._pvzEngine.updateRecordDisplay === 'function') window._pvzEngine.updateRecordDisplay(); if (typeof window._pvzEngine.updateShopUI === 'function') window._pvzEngine.updateShopUI(); } notifyProfileChange(_cachedData); return _cachedData; } } catch (e) { console.warn('Profile fetch from server error:', e); } return null; }, async syncFromServer() { return this.fetchProfile(); }, /** * Active Device Sessions Management */ async fetchSessions() { const token = getToken(); if (!token || this.isGuest()) return []; try { const res = await apiGet('sessions'); if (res && res.success && Array.isArray(res.sessions)) { return res.sessions.map(s => ({ token: s.token || '', deviceName: s.deviceName || s.device_name || 'Браузер / Компьютер', ipAddress: s.ipAddress || s.ip_address || '127.0.0.1', createdAt: s.createdAt || s.created_at || '', lastActive: s.lastActive || s.last_active || s.createdAt || '', isCurrent: Boolean(s.isCurrent || s.is_current || (token && s.token === token)) })); } } catch (e) { console.error('Fetch sessions error:', e); } return []; }, async terminateSession(sessionToken) { if (!sessionToken) return { success: false, message: 'Не указан токен сессии' }; const res = await apiPost('sessions/terminate', { sessionToken, token: sessionToken }); return res || { success: false, message: 'Ошибка сети' }; }, async terminateOtherSessions() { const res = await apiPost('sessions/terminate', { allOthers: true, all_others: true }); return res || { success: false, message: 'Ошибка сети' }; }, getUserData() { if (_cachedData) return _cachedData; const local = localLoad(); if (local) { _cachedData = mergeUserData(local); } else { _cachedData = getDefaultData(); } // Background refresh from server if logged in if (!this.isGuest() && getToken()) { this.fetchProfile(); } 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 (!_hasSyncedFromServer && getToken()) return; // Don't flush stale unsynced local cache over server 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); }); // Auto-sync user data on startup if logged in setTimeout(() => { if (!authInstance.isGuest() && authInstance.getToken()) { authInstance.syncFromServer(); } }, 150); } return authInstance; })();