// 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 = `
${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; }
}
/* ─── 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,
stats: {
...def.stats,
...(serverData.stats || {})
},
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;
})();