feat(gameplay): add crazy dave casino and bank, and zombie mutation lab

This commit is contained in:
2026-09-22 09:25:08 +00:00
parent d8be8f4366
commit b8db5a123d
16 changed files with 2365 additions and 135 deletions
+238 -20
View File
@@ -131,6 +131,52 @@ window.PvZAuth = (function () {
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 {
@@ -172,41 +218,90 @@ window.PvZAuth = (function () {
/* ─── Debounced save (prevents save storms on rapid updates) ─── */
let _saveTimer = null;
let _hasSyncedFromServer = false;
function scheduleSave() {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => {
_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) {
function mergeUserData(serverData, localData = null) {
const def = getDefaultData();
if (!serverData) return def;
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,
...serverData,
...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,
...(serverData.stats || {})
...(loc.stats || {}),
...(base.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
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,
...serverData.zenGarden,
pots: serverData.zenGarden.pots || def.zenGarden.pots,
snail: { ...def.zenGarden.snail, ...(serverData.zenGarden.snail || {}) }
...(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 || {}) }
}
: def.zenGarden,
treeOfWisdom: serverData.treeOfWisdom || def.treeOfWisdom,
achievements: serverData.achievements || {}
: (loc.zenGarden || def.zenGarden),
treeOfWisdom: base.treeOfWisdom || loc.treeOfWisdom || def.treeOfWisdom,
achievements: {
...(loc.achievements || {}),
...(base.achievements || {})
}
};
}
@@ -228,9 +323,13 @@ window.PvZAuth = (function () {
getDefaultData,
validateUsername,
validatePassword,
apiGet,
apiPost,
scheduleSave,
showNotification,
onProfileChange,
offProfileChange,
notifyProfileChange,
/**
* User Login
@@ -263,7 +362,11 @@ window.PvZAuth = (function () {
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 {
@@ -305,7 +408,11 @@ window.PvZAuth = (function () {
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 {
@@ -318,6 +425,7 @@ window.PvZAuth = (function () {
loginAsGuest() {
clearToken();
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
notifyProfileChange(this.getUserData());
return { success: true, user: 'Гость' };
},
@@ -334,16 +442,118 @@ window.PvZAuth = (function () {
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);
return _cachedData;
} else {
_cachedData = getDefaultData();
}
// Background refresh from server if logged in
if (!this.isGuest() && getToken()) {
this.fetchProfile();
}
_cachedData = getDefaultData();
return _cachedData;
},
@@ -362,6 +572,7 @@ window.PvZAuth = (function () {
/** 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);
},
@@ -527,6 +738,13 @@ window.PvZAuth = (function () {
}
authInstance.flushSave(true);
});
// Auto-sync user data on startup if logged in
setTimeout(() => {
if (!authInstance.isGuest() && authInstance.getToken()) {
authInstance.syncFromServer();
}
}, 150);
}
return authInstance;