'use strict'; const db = require('../db/db'); const { awardCoins } = require('./gamificationController'); const { ACHIEVEMENT_DEFS } = require('./gamification/_shared'); /* ── XP-log reason → человекочитаемый ярлык для ленты активности питомца ── Сырые коды причин (`achievement:slug`, `tb:...`, `lesson_complete`, …) не должны попадать в UID — резолвим их в понятные подписи. */ const SOURCE_LABELS = { hangman_win: 'Виселица', crossword_win: 'Кроссворд', test_answer: 'Тест', challenge: 'Задание', lab_activity: 'Лаборатория', lab_experiment: 'Лаборатория', assignment: 'Домашнее задание', pet_petting: 'Поглаживание', pet_feeding: 'Кормёжка питомца', mood_ecstatic: 'Бонус настроения', correct_answers: 'Тест', test_complete: 'Тест', 'test_90+': 'Тест 90%+', test_perfect: 'Тест 100%', daily_activity: 'Активность дня', daily_goal: 'Цель дня', lesson_complete: 'Урок пройден', }; const _ACH_TITLE = new Map(ACHIEVEMENT_DEFS.map(a => [a.slug, a.title])); function _xpReasonLabel(reason) { if (!reason) return 'Награда'; if (reason.startsWith('achievement:')) { const t = _ACH_TITLE.get(reason.slice('achievement:'.length)); return t ? `Достижение «${t}»` : 'Достижение'; } if (reason.startsWith('tb:')) return 'Учебник'; if (SOURCE_LABELS[reason]) return SOURCE_LABELS[reason]; // Уже читаемая фраза (например «Испытание: …») — содержит пробел/кириллицу. if (/[А-Яа-яЁё ]/.test(reason)) return reason; return 'Награда'; } const BG_SHOP = [ { id:'space', name:'Космос', price:50, desc:'Звёздный простор' }, { id:'forest', name:'Лес', price:50, desc:'Таинственный лес' }, { id:'aqua', name:'Океан', price:75, desc:'Морская глубина' }, { id:'sunset', name:'Закат', price:75, desc:'Пурпурный закат' }, { id:'aurora', name:'Сияние', price:100, desc:'Северное сияние' }, { id:'candy', name:'Леденец', price:100, desc:'Сладкие облака' }, { id:'sakura', name:'Сакура', price:125, desc:'Цветущая вишня' }, { id:'class', name:'Класс', price:75, desc:'Школьный класс' }, { id:'lab', name:'Лаборатория', price:100, desc:'Химлаборатория' }, { id:'winter', name:'Зима', price:100, desc:'Снежная ночь' }, { id:'rainbow', name:'Радуга', price:125, desc:'Радужное небо' }, ]; function _parseOwned(raw) { try { return JSON.parse(raw || '[]'); } catch { return []; } } function _petLevel(xp) { if (xp >= 80000) return 8; if (xp >= 40000) return 7; if (xp >= 20000) return 6; if (xp >= 10000) return 5; if (xp >= 5000) return 4; if (xp >= 2000) return 3; if (xp >= 500) return 2; return 1; } function _mood(streak, daysSinceLogin) { if (daysSinceLogin >= 14) return 'sleeping'; if (daysSinceLogin >= 7) return 'hungry'; if (daysSinceLogin >= 3) return 'sad'; if (streak >= 7) return 'ecstatic'; if (streak >= 3) return 'happy'; if (streak >= 1) return 'neutral'; return 'sad'; } /* ── Гардероб: каталог аксессуаров (разблокировка по порогам/бесплатно) ── slot — на каждый слот можно надеть только один предмет. */ const ACCESSORY_CATALOG = [ // голова { id: 'grad', name: 'Шапочка выпускника', slot: 'head', unlock: { type: 'free' } }, { id: 'party', name: 'Праздничный колпак', slot: 'head', unlock: { type: 'free' } }, { id: 'beanie', name: 'Шапка-бини', slot: 'head', unlock: { type: 'free' } }, { id: 'hat', name: 'Цилиндр', slot: 'head', unlock: { type: 'streak', value: 7 } }, { id: 'halo', name: 'Нимб', slot: 'head', unlock: { type: 'achievements', value: 8 } }, { id: 'crown', name: 'Корона', slot: 'head', unlock: { type: 'xp', value: 5000 } }, // лицо { id: 'glasses', name: 'Очки', slot: 'face', unlock: { type: 'level', value: 5 } }, { id: 'sunglasses', name: 'Тёмные очки', slot: 'face', unlock: { type: 'free' } }, { id: 'monocle', name: 'Монокль', slot: 'face', unlock: { type: 'free' } }, // шея { id: 'bowtie', name: 'Бабочка', slot: 'neck', unlock: { type: 'free' } }, { id: 'scarf', name: 'Шарф', slot: 'neck', unlock: { type: 'free' } }, { id: 'medal', name: 'Медаль', slot: 'neck', unlock: { type: 'tests', value: 30 } }, // уши { id: 'headphones', name: 'Наушники', slot: 'ears', unlock: { type: 'free' } }, { id: 'earrings', name: 'Серёжки', slot: 'ears', unlock: { type: 'free' } }, // в лапах { id: 'wand', name: 'Волшебная палочка', slot: 'hands', unlock: { type: 'free' } }, { id: 'balloon', name: 'Шарик', slot: 'hands', unlock: { type: 'free' } }, // акцент { id: 'flower', name: 'Цветок', slot: 'accent', unlock: { type: 'free' } }, { id: 'star', name: 'Звезда', slot: 'accent', unlock: { type: 'level', value: 10 } }, ]; const ACC_BY_ID = new Map(ACCESSORY_CATALOG.map(a => [a.id, a])); // Узоры тела (новая ось кастомизации). Все бесплатны. const PET_PATTERNS = [ { id: 'none', name: 'Без узора' }, { id: 'spots', name: 'Пятнышки' }, { id: 'stripes', name: 'Полоски' }, { id: 'gradient', name: 'Градиент' }, { id: 'galaxy', name: 'Галактика' }, { id: 'hearts', name: 'Сердечки' }, { id: 'stars', name: 'Звёздочки' }, { id: 'checker', name: 'Клетка' }, ]; const PATTERN_IDS = new Set(PET_PATTERNS.map(p => p.id)); function _accUnlocked(u, a, st) { const k = a.unlock; if (k.type === 'free') return true; if (k.type === 'xp') return (u.xp || 0) >= k.value; if (k.type === 'level') return (u.level || 0) >= k.value; if (k.type === 'streak') return (u.streak_best || 0) >= k.value; if (k.type === 'tests') return (st && st.tests || 0) >= k.value; if (k.type === 'achievements') return (st && st.achievements || 0) >= k.value; return false; } function _unlockHint(a) { const k = a.unlock; if (k.type === 'xp') return `${k.value} XP`; if (k.type === 'level') return `уровень ${k.value}`; if (k.type === 'streak') return `серия ${k.value} дн.`; if (k.type === 'tests') return `${k.value} тестов`; if (k.type === 'achievements') return `${k.value} достижений`; return ''; } // Статистика для разблокировки косметики за учёбу. function _petStats(userId) { let tests = 0, achievements = 0; try { tests = db.prepare("SELECT COUNT(*) n FROM test_sessions WHERE user_id=? AND status='completed'").get(userId).n; } catch (e) {} try { achievements = db.prepare("SELECT COUNT(*) n FROM user_achievements WHERE user_id=?").get(userId).n; } catch (e) {} return { tests, achievements }; } // Дефолт при первом заходе (pet_equipped == null) — повторяет прежнее авто-поведение. function _defaultEquipped(u) { const eq = []; if ((u.xp || 0) >= 5000) eq.push('crown'); if ((u.level || 0) >= 5) eq.push('glasses'); if ((u.level || 0) >= 10) eq.push('star'); if ((u.streak_best || 0) >= 7) eq.push('hat'); return eq; } function _quests(userId, streakCurrent) { const today = new Date().toISOString().slice(0, 10); const xpToday = db.prepare( "SELECT COALESCE(SUM(amount),0) AS total FROM xp_log WHERE user_id=? AND date(created_at)=date(?)" ).get(userId, today)?.total || 0; const testsToday = db.prepare( "SELECT COUNT(*) AS cnt FROM test_sessions WHERE user_id=? AND status='completed' AND date(finished_at)=date(?)" ).get(userId, today)?.cnt || 0; return [ { id:'xp30', icon:'⭐', label:'Набери 30 XP сегодня', done: xpToday >= 30, progress: Math.min(xpToday, 30), goal: 30 }, { id:'test1', icon:'📝', label:'Пройди 1 тест', done: testsToday >= 1, progress: Math.min(testsToday, 1), goal: 1 }, { id:'streak2', icon:'🔥', label:'Серия 2+ дней', done: streakCurrent >= 2 }, ]; } function _moodForecast(daysSince) { if (daysSince >= 7) return null; // already hungry/sleeping if (daysSince >= 3) return { mood: 'hungry', inDays: Math.max(0, 7 - daysSince) }; const toSad = Math.max(0, 3 - daysSince); if (toSad <= 2) return { mood: 'sad', inDays: toSad }; return null; } /* ── GET /api/pet ─────────────────────────────────────────────────────── */ function getPet(req, res) { const user = db.prepare( `SELECT xp, level, streak_current, streak_best, streak_date, coins, pet_name, last_login, pet_color, pet_last_petted, pet_petting_streak, pet_bg, pet_bg_owned, pet_last_fed, pet_equipped, pet_pattern, pet_bg_custom FROM users WHERE id = ?` ).get(req.user.id); if (!user) return res.status(404).json({ error: 'User not found' }); const now = new Date(); const lastXpRow = db.prepare("SELECT MAX(created_at) AS t FROM xp_log WHERE user_id = ?").get(req.user.id); const lastSessRow = db.prepare("SELECT MAX(finished_at) AS t FROM test_sessions WHERE user_id = ? AND status = 'completed'").get(req.user.id); const candidates = [ user.last_login ? new Date(user.last_login) : null, lastXpRow?.t ? new Date(lastXpRow.t) : null, lastSessRow?.t ? new Date(lastSessRow.t) : null, ].filter(Boolean); const lastActive = candidates.length ? new Date(Math.max(...candidates.map(d => d.getTime()))) : now; const daysSince = Math.max(0, Math.floor((now - lastActive) / 86400000)); const since7 = new Date(now - 7 * 86400000).toISOString(); const recentXP = db.prepare( 'SELECT COALESCE(SUM(amount),0) AS total FROM xp_log WHERE user_id = ? AND created_at >= ?' ).get(req.user.id, since7)?.total || 0; const petLvl = _petLevel(user.xp); const mood = _mood(user.streak_current, daysSince); // Гардероб: разблокированные + надетые (equipped). При первом заходе — дефолт. const stats = _petStats(req.user.id); const unlocked = ACCESSORY_CATALOG.filter(a => _accUnlocked(user, a, stats)).map(a => a.id); let equipped; if (user.pet_equipped == null) equipped = _defaultEquipped(user); else { try { equipped = JSON.parse(user.pet_equipped) || []; } catch (e) { equipped = []; } } equipped = equipped.filter(id => unlocked.includes(id)); // только разблокированные const accessories = equipped; const wardrobe = ACCESSORY_CATALOG.map(a => ({ id: a.id, name: a.name, slot: a.slot, locked: !unlocked.includes(a.id), hint: unlocked.includes(a.id) ? '' : _unlockHint(a), equipped: equipped.includes(a.id), })); const thresholds = [0, 500, 2000, 5000, 10000, 20000, 40000, 80000, Infinity]; const xpForNext = thresholds[petLvl] ?? Infinity; const xpForCurr = thresholds[petLvl - 1] ?? 0; const d0 = new Date(now); d0.setDate(d0.getDate() - 6); const weekStart = d0.toISOString().slice(0, 10); const weekRows = db.prepare( "SELECT date(created_at) AS d, COALESCE(SUM(amount),0) AS xp FROM xp_log WHERE user_id = ? AND date(created_at) >= date(?) GROUP BY date(created_at)" ).all(req.user.id, weekStart); const weekMap = new Map(weekRows.map(r => [r.d, r.xp])); const weeklyXP = []; for (let i = 6; i >= 0; i--) { const d = new Date(now); d.setDate(d.getDate() - i); const dateStr = d.toISOString().slice(0, 10); weeklyXP.push({ day: d.toLocaleDateString('ru', { weekday: 'short' }), xp: weekMap.get(dateStr) || 0 }); } const rawActivity = db.prepare( "SELECT amount, reason, created_at FROM xp_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 6" ).all(req.user.id); const recentActivity = rawActivity.map(r => ({ xp: r.amount, label: _xpReasonLabel(r.reason), at: r.created_at, })); const pettingCooldown = user.pet_last_petted ? Math.max(0, 60 - Math.floor((now - new Date(user.pet_last_petted)) / 1000)) : 0; const feedCooldown = user.pet_last_fed ? Math.max(0, 1800 - Math.floor((now - new Date(user.pet_last_fed)) / 1000)) : 0; res.json({ petName: user.pet_name || 'Квантик', petLevel: petLvl, petColor: user.pet_color || 'purple', petPattern: user.pet_pattern || 'none', patterns: PET_PATTERNS, mood, daysSinceLogin: daysSince, accessories, wardrobe, xp: user.xp, level: user.level, streakCurrent: user.streak_current, streakBest: user.streak_best, coins: user.coins, pettingStreak: user.pet_petting_streak || 0, recentXP, weeklyXP, recentActivity, xpForCurrLevel: xpForCurr, xpForNextLevel: xpForNext === Infinity ? null : xpForNext, quests: _quests(req.user.id, user.streak_current), moodForecast: _moodForecast(daysSince), pettingCooldown, feedCooldown, petBg: user.pet_bg || 'default', petBgOwned: _parseOwned(user.pet_bg_owned), petBgCustom: user.pet_bg_custom || null, }); } /* ── POST /api/pet/pet ────────────────────────────────────────────────── */ function petAction(req, res) { const user = db.prepare('SELECT pet_last_petted, pet_petting_streak FROM users WHERE id=?').get(req.user.id); if (!user) return res.status(404).json({ error: 'not found' }); const now = new Date(); if (user.pet_last_petted) { const diff = (now - new Date(user.pet_last_petted)) / 1000; if (diff < 60) return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(60 - diff) }); } const today = now.toISOString().slice(0, 10); const yesterday = new Date(now - 86400000).toISOString().slice(0, 10); let streak = user.pet_petting_streak || 0; if (user.pet_last_petted) { const lastDate = user.pet_last_petted.slice(0, 10); if (lastDate === today) { /* same day, keep */ } else if (lastDate === yesterday) streak++; else streak = 1; } else { streak = 1; } // CAS: апдейт проходит, только если pet_last_petted не изменился с момента чтения // (IS — null-safe). Защита от гонки двойного начисления при параллельных запросах. const claim = db.prepare('UPDATE users SET pet_last_petted=?, pet_petting_streak=? WHERE id=? AND pet_last_petted IS ?') .run(now.toISOString(), streak, req.user.id, user.pet_last_petted); if (claim.changes === 0) return res.status(429).json({ error: 'cooldown', remaining: 1 }); try { awardCoins(req.user.id, 2, 'pet_petting'); } catch {} res.json({ ok: true, coins: 2, pettingStreak: streak }); } /* ── PATCH /api/pet/name ──────────────────────────────────────────────── */ function renamePet(req, res) { let { name } = req.body; if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' }); name = name.trim().slice(0, 24); if (!name) return res.status(400).json({ error: 'name required' }); db.prepare('UPDATE users SET pet_name = ? WHERE id = ?').run(name, req.user.id); res.json({ ok: true, name }); } /* ── PATCH /api/pet/color ─────────────────────────────────────────────── */ function updateColor(req, res) { const { color } = req.body; const VALID = ['purple', 'cyan', 'gold', 'red', 'green', 'blue', 'pink', 'orange', 'teal', 'lime', 'indigo']; if (!VALID.includes(color)) return res.status(400).json({ error: 'invalid color' }); db.prepare('UPDATE users SET pet_color = ? WHERE id = ?').run(color, req.user.id); res.json({ ok: true, color }); } /* ── PATCH /api/pet/pattern — узор тела ───────────────────────────────── */ function updatePattern(req, res) { const pattern = req.body && req.body.pattern; if (!PATTERN_IDS.has(pattern)) return res.status(400).json({ error: 'invalid pattern' }); db.prepare('UPDATE users SET pet_pattern = ? WHERE id = ?').run(pattern, req.user.id); res.json({ ok: true, pattern }); } /* ── PATCH /api/pet/equip — надеть/снять аксессуары (гардероб) ─────────── */ function equipAccessories(req, res) { const list = req.body && req.body.equipped; if (!Array.isArray(list)) return res.status(400).json({ error: 'equipped[] required' }); const user = db.prepare('SELECT xp, level, streak_best FROM users WHERE id=?').get(req.user.id); if (!user) return res.status(404).json({ error: 'not found' }); const stats = _petStats(req.user.id); // оставляем только существующие, разблокированные, по одному на слот const seenSlot = new Set(), clean = []; for (const id of list) { const a = ACC_BY_ID.get(id); if (!a || !_accUnlocked(user, a, stats) || seenSlot.has(a.slot)) continue; seenSlot.add(a.slot); clean.push(id); } db.prepare('UPDATE users SET pet_equipped=? WHERE id=?').run(JSON.stringify(clean), req.user.id); res.json({ ok: true, equipped: clean }); } /* ── GET /api/pet/shop ────────────────────────────────────────────────── */ function getShop(req, res) { const user = db.prepare('SELECT coins, pet_bg, pet_bg_owned FROM users WHERE id=?').get(req.user.id); if (!user) return res.status(404).json({ error: 'not found' }); const owned = _parseOwned(user.pet_bg_owned); res.json({ coins: user.coins || 0, currentBg: user.pet_bg || 'default', items: BG_SHOP.map(item => ({ ...item, owned: owned.includes(item.id) })), }); } /* ── POST /api/pet/shop/buy ───────────────────────────────────────────── */ function buyBg(req, res) { const { id } = req.body; const item = BG_SHOP.find(b => b.id === id); if (!item) return res.status(400).json({ error: 'invalid item' }); const user = db.prepare('SELECT coins, pet_bg_owned FROM users WHERE id=?').get(req.user.id); if (!user) return res.status(404).json({ error: 'not found' }); const owned = _parseOwned(user.pet_bg_owned); if (!owned.includes(id)) { // Atomic conditional UPDATE — race-safe even under concurrent purchase attempts const result = db.prepare( 'UPDATE users SET coins = coins - ?, pet_bg_owned = ?, pet_bg = ? WHERE id = ? AND coins >= ?' ).run(item.price, JSON.stringify([...owned, id]), id, req.user.id, item.price); if (result.changes === 0) return res.status(400).json({ error: 'insufficient_coins' }); } else { db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id); } const updated = db.prepare('SELECT coins FROM users WHERE id=?').get(req.user.id); res.json({ ok: true, bg: id, coins: updated.coins }); } /* ── PATCH /api/pet/bg ────────────────────────────────────────────────── */ function setBg(req, res) { const { id } = req.body; if (id === 'custom') { const url = db.prepare('SELECT pet_bg_custom FROM users WHERE id=?').get(req.user.id)?.pet_bg_custom; if (!url) return res.status(400).json({ error: 'no_custom_bg' }); } else if (id !== 'default') { const owned = _parseOwned(db.prepare('SELECT pet_bg_owned FROM users WHERE id=?').get(req.user.id)?.pet_bg_owned); if (!owned.includes(id)) return res.status(403).json({ error: 'not owned' }); } db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id); res.json({ ok: true, bg: id }); } /* ── POST /api/pet/bg/custom ────────────────────────────────────────────── Сохранить сгенерированную ИИ картинку как кастомный фон и сделать активной. URL принимается только из /uploads/generated/ (то, что отдаёт /api/imggen). */ function setCustomBg(req, res) { const url = String(req.body && req.body.url || ''); if (!/^\/uploads\/generated\/[A-Za-z0-9._-]+\.(png|jpg|jpeg|webp)$/.test(url)) { return res.status(400).json({ error: 'invalid_url' }); } db.prepare('UPDATE users SET pet_bg_custom=?, pet_bg=? WHERE id=?').run(url, 'custom', req.user.id); res.json({ ok: true, bg: 'custom', url }); } /* ── POST /api/pet/star ───────────────────────────────────────────────── */ function starCatch(req, res) { const user = db.prepare('SELECT pet_last_star FROM users WHERE id=?').get(req.user.id); if (!user) return res.status(404).json({ error: 'not found' }); const now = new Date(); if (user.pet_last_star) { const diff = (now - new Date(user.pet_last_star)) / 1000; if (diff < 3600) return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(3600 - diff) }); } const claim = db.prepare('UPDATE users SET pet_last_star=? WHERE id=? AND pet_last_star IS ?') .run(now.toISOString(), req.user.id, user.pet_last_star); if (claim.changes === 0) return res.status(429).json({ error: 'cooldown', remaining: 1 }); try { awardCoins(req.user.id, 5, 'star_catch'); } catch {} res.json({ ok: true, coins: 5 }); } /* ── POST /api/pet/feed ───────────────────────────────────────────────── */ // Called when mini-game "feed pet" is correctly answered on frontend. // 30-minute cooldown; awards 15 XP + marks pet as fed. function feedPet(req, res) { const user = db.prepare('SELECT pet_last_fed FROM users WHERE id=?').get(req.user.id); if (!user) return res.status(404).json({ error: 'not found' }); const now = new Date(); const COOLDOWN_SEC = 1800; // 30 minutes if (user.pet_last_fed) { const diff = (now - new Date(user.pet_last_fed)) / 1000; if (diff < COOLDOWN_SEC) { return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(COOLDOWN_SEC - diff) }); } } // CAS-«застолбить» кулдаун ДО начисления XP (анти-гонка двойного начисления) const claim = db.prepare('UPDATE users SET pet_last_fed=? WHERE id=? AND pet_last_fed IS ?') .run(now.toISOString(), req.user.id, user.pet_last_fed); if (claim.changes === 0) return res.status(429).json({ error: 'cooldown', remaining: 1 }); try { const { awardXP } = require('./gamificationController'); awardXP(req.user.id, 15, 'pet_feeding'); } catch (e) { console.error('[feedPet] awardXP:', e.message); } const updated = db.prepare('SELECT xp, coins FROM users WHERE id=?').get(req.user.id); res.json({ ok: true, xpAwarded: 15, xp: updated.xp, coins: updated.coins }); } module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, setCustomBg, feedPet, equipAccessories, updatePattern };