Files
Learn_System/backend/src/controllers/petController.js
T
Maxim Dolgolyov 7b653d92c2 fix(pet): человекочитаемые подписи в ленте XP питомца
Лента «последних начислений» печатала сырые коды причин из xp_log
(achievement:cr_5_lessons, tb:math-6-ch1-ach-start, lesson_complete).
Добавлен резолвер _xpReasonLabel: achievement:<slug> -> «Достижение
«<title>»» через ACHIEVEMENT_DEFS, tb:* -> «Учебник», недостающие
фиксированные причины (lesson_complete/daily_goal/daily_activity/
lab_experiment), сохранение уже-читаемых фраз, фолбэк «Награда».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:39:43 +03:00

320 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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:'Пурпурный закат' },
];
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';
}
function _accessories(user) {
const acc = [];
if (user.streak_best >= 7) acc.push('hat');
if (user.level >= 5) acc.push('glasses');
if (user.xp >= 5000) acc.push('crown');
if (user.level >= 10) acc.push('star');
return acc;
}
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
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);
const accessories = _accessories(user);
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',
mood,
daysSinceLogin: daysSince,
accessories,
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),
});
}
/* ── 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;
}
try { awardCoins(req.user.id, 2, 'pet_petting'); } catch {}
db.prepare('UPDATE users SET pet_last_petted=?, pet_petting_streak=? WHERE id=?')
.run(now.toISOString(), streak, req.user.id);
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'];
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 });
}
/* ── 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 !== '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/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) });
}
try { awardCoins(req.user.id, 5, 'star_catch'); } catch {}
db.prepare('UPDATE users SET pet_last_star=? WHERE id=?').run(now.toISOString(), req.user.id);
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) });
}
}
try {
const { awardXP } = require('./gamificationController');
awardXP(req.user.id, 15, 'pet_feeding');
} catch (e) { console.error('[feedPet] awardXP:', e.message); }
db.prepare('UPDATE users SET pet_last_fed=? WHERE id=?').run(now.toISOString(), req.user.id);
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, feedPet };