// 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 save file - disabled in favor of secure cloud saves */ exportSaveFile() { showNotification('Облачное сохранение', 'Все данные сохраняются на сервере. Экспорт в файл отключен.', '☁️'); return { success: false, message: 'Локальный экспорт отключен' }; }, /** * Import save file - disabled in favor of secure cloud saves */ async importSaveFile(file) { showNotification('Облачный профиль', 'Импорт локальных файлов отключен. Прогресс загружается с сервера.', '🔒'); return { success: false, 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; })();