Files
Learn_System/backend/src/controllers/petController.js
T
Maxim Dolgolyov 09c6c2b21d fix(reliability): multer-ошибки, process-хендлеры, анти-гонка питомца, flashcards (Спринт2)
- errorHandler: MulterError → 413 «слишком большой» / 400 (а не 500).
- server: process.on(unhandledRejection/uncaughtException) — глобальная страховка
  с логированием, процесс не падает от единичной асинхронной ошибки.
- pet: атомарный CAS на кулдаунах petAction/starCatch/feedPet
  (UPDATE ... WHERE last IS ?, начисление только при changes=1) — нет двойного
  начисления при параллельных запросах. Проверено на семантике node:sqlite.
- assistant.flashcardsFromText: await callLLMFailover в try/catch → 502 вместо
  необработанного отклонения промиса.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 22:08:02 +03:00

464 lines
24 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:'Пурпурный закат' },
{ 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 };