282 lines
10 KiB
JavaScript
282 lines
10 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 LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache
|
||
const API_BASE = '/api/auth';
|
||
|
||
/* ─── 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 };
|
||
}
|
||
|
||
/* ─── 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 ─── */
|
||
async function apiPost(endpoint, body) {
|
||
try {
|
||
const resp = await fetch(`${API_BASE}/${endpoint}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
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 || {}
|
||
};
|
||
}
|
||
|
||
return {
|
||
/* ── Public API ── */
|
||
|
||
getCurrentUser() {
|
||
return localStorage.getItem(CURRENT_USER_KEY) || 'Гость';
|
||
},
|
||
|
||
isGuest() {
|
||
const u = localStorage.getItem(CURRENT_USER_KEY);
|
||
return !u || u === 'Гость';
|
||
},
|
||
|
||
getDefaultData,
|
||
validateUsername,
|
||
validatePassword,
|
||
apiPost,
|
||
scheduleSave,
|
||
|
||
/**
|
||
* 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);
|
||
_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, 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 = getDefaultData();
|
||
|
||
const res = await apiPost('register', { username: cleanUser, passwordHash: pwdHash, initialData: initData });
|
||
|
||
if (res && res.success) {
|
||
localStorage.setItem(CURRENT_USER_KEY, cleanUser);
|
||
_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, data: _cachedData };
|
||
} else {
|
||
const errorMsg = (res && res.message) ? res.message : 'Ошибка при регистрации';
|
||
if (typeof callback === 'function') callback(false, null, errorMsg);
|
||
return { success: false, message: errorMsg };
|
||
}
|
||
},
|
||
|
||
loginAsGuest() {
|
||
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
|
||
_cachedData = null;
|
||
localSave(null);
|
||
return { success: true, user: 'Гость' };
|
||
},
|
||
|
||
logout() {
|
||
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
||
const user = this.getCurrentUser();
|
||
if (user !== 'Гость' && _cachedData) {
|
||
apiPost('save', { username: user, data: _cachedData });
|
||
}
|
||
this.loginAsGuest();
|
||
},
|
||
|
||
getUserData() {
|
||
if (this.isGuest()) return getDefaultData();
|
||
|
||
if (_cachedData) return _cachedData;
|
||
|
||
const local = localLoad();
|
||
if (local) {
|
||
_cachedData = local;
|
||
return local;
|
||
}
|
||
|
||
_cachedData = getDefaultData();
|
||
return _cachedData;
|
||
},
|
||
|
||
saveUserData(data) {
|
||
if (this.isGuest()) return; // Guests are not persisted
|
||
|
||
_cachedData = _cachedData
|
||
? { ..._cachedData, ...data }
|
||
: { ...getDefaultData(), ...data };
|
||
|
||
localSave(_cachedData); // instant local write
|
||
scheduleSave(); // debounced server sync
|
||
},
|
||
|
||
/** Force immediate server save (call on game-over / victory) */
|
||
flushSave() {
|
||
if (this.isGuest() || !_cachedData) return;
|
||
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
||
apiPost('save', { username: this.getCurrentUser(), data: _cachedData });
|
||
}
|
||
};
|
||
})();
|