fix(anti-cheat): анти-фарм XP в играх и при повторном завершении урока (Спринт1 #2,#3)

- games: дневной лимит начислений XP за hangman/crossword (DAILY_WIN_CAP=10,
  счёт по xp_log.reason) — нельзя бесконечно фармить циклом complete.
- lessons.markComplete: XP/монеты только при ПЕРВОМ завершении урока
  (повторные POST больше ничего не начисляют).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-12 21:54:41 +03:00
parent 840bb823b9
commit dd5dfee5c9
2 changed files with 15 additions and 1 deletions
@@ -233,6 +233,14 @@ function hangmanWord(req, res) {
}); });
} }
// Анти-фарм: XP за игры начисляется по «честному слову» клиента, поэтому
// ограничиваем число начислений за тип игры в сутки (счёт по xp_log.reason).
const DAILY_WIN_CAP = 10;
function _gameWinsToday(userId, reason) {
try { return db.prepare("SELECT COUNT(*) AS n FROM xp_log WHERE user_id=? AND reason=? AND created_at >= date('now')").get(userId, reason).n; }
catch (e) { return 0; }
}
/* ── POST /api/games/hangman/complete ─────────────────────────────────── */ /* ── POST /api/games/hangman/complete ─────────────────────────────────── */
function hangmanComplete(req, res) { function hangmanComplete(req, res) {
const { won, errors } = req.body; const { won, errors } = req.body;
@@ -243,6 +251,7 @@ function hangmanComplete(req, res) {
// 15 XP perfect, -2 per error, min 5 // 15 XP perfect, -2 per error, min 5
xpGain = Math.max(5, 15 - (Number(errors) || 0) * 2); xpGain = Math.max(5, 15 - (Number(errors) || 0) * 2);
} }
if (xpGain > 0 && _gameWinsToday(req.user.id, 'hangman_win') >= DAILY_WIN_CAP) xpGain = 0;
if (xpGain > 0) { if (xpGain > 0) {
try { awardXP(req.user.id, xpGain, 'hangman_win'); } catch (e) { console.error('[games] hangman XP:', e.message); } try { awardXP(req.user.id, xpGain, 'hangman_win'); } catch (e) { console.error('[games] hangman XP:', e.message); }
@@ -299,6 +308,7 @@ function crosswordComplete(req, res) {
if (completed) { if (completed) {
xpGain = Math.max(5, 20 - (Number(hintsUsed) || 0) * 3); xpGain = Math.max(5, 20 - (Number(hintsUsed) || 0) * 3);
} }
if (xpGain > 0 && _gameWinsToday(req.user.id, 'crossword_win') >= DAILY_WIN_CAP) xpGain = 0;
if (xpGain > 0) { if (xpGain > 0) {
try { awardXP(req.user.id, xpGain, 'crossword_win'); } catch (e) { console.error('[games] crossword XP:', e.message); } try { awardXP(req.user.id, xpGain, 'crossword_win'); } catch (e) { console.error('[games] crossword XP:', e.message); }
+5 -1
View File
@@ -186,6 +186,10 @@ function markComplete(req, res) {
const lesson = db.prepare('SELECT * FROM lessons WHERE id = ?').get(req.params.id); const lesson = db.prepare('SELECT * FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' }); if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
// Награду даём только при ПЕРВОМ завершении (анти-фарм повторными POST).
const prev = db.prepare('SELECT completed FROM lesson_progress WHERE user_id = ? AND lesson_id = ?').get(req.user.id, lesson.id);
const firstCompletion = !prev || prev.completed !== 1;
db.prepare(` db.prepare(`
INSERT INTO lesson_progress (user_id, lesson_id, completed, updated_at) INSERT INTO lesson_progress (user_id, lesson_id, completed, updated_at)
VALUES (?, ?, 1, datetime('now')) VALUES (?, ?, 1, datetime('now'))
@@ -201,7 +205,7 @@ function markComplete(req, res) {
WHERE l.course_id = ? AND lp.user_id = ? AND lp.completed = 1 WHERE l.course_id = ? AND lp.user_id = ? AND lp.completed = 1
`).get(lesson.course_id, req.user.id).n; `).get(lesson.course_id, req.user.id).n;
try { onLessonComplete(req.user.id, lesson.course_id); } catch {} if (firstCompletion) { try { onLessonComplete(req.user.id, lesson.course_id); } catch {} }
res.json({ ok: true, courseComplete: done >= total && total > 0 }); res.json({ ok: true, courseComplete: done >= total && total > 0 });
} }