625 lines
22 KiB
JavaScript
625 lines
22 KiB
JavaScript
// 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 = `
|
||
<div class="ach-toast-icon">${icon}</div>
|
||
<div class="ach-toast-text">
|
||
<div class="ach-toast-subtitle">${subtitle || 'УВЕДОМЛЕНИЕ'}</div>
|
||
<div class="ach-toast-title">${title}</div>
|
||
</div>
|
||
`;
|
||
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;
|
||
})();
|