'use strict'; const db = require('../db/db'); const { awardXP } = require('./gamificationController'); /* ── Crossword generator (improved) ──────────────────────────────────── */ // Algorithm based on MichaelWehar/crossword-layout-generator scoring with additions: // 1. Enumerate candidates from already-placed words (only perpendicular directions) // 2. Weighted score: connections 70% + center 15% + orientation balance 10% + length 5% // 3. Two-pass: retry skipped words after full first pass // 4. Random pick from top-3 candidates per attempt for variety // 5. 120 attempts, keeping best result by placed-word count const CW_GRID = 20; const CW_MAX_WORDS = 10; const CW_ATTEMPTS = 120; function _shuffle(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function _cwPlace(grid, word, row, col, dir) { for (let i = 0; i < word.length; i++) { if (dir === 'across') grid[row][col + i] = word[i]; else grid[row + i][col] = word[i]; } } // Returns number of valid intersections (≥0) if placement is valid, -1 if invalid function _cwCheck(grid, word, row, col, dir, N) { if (row < 0 || col < 0) return -1; const endR = dir === 'down' ? row + word.length - 1 : row; const endC = dir === 'across' ? col + word.length - 1 : col; if (endR >= N || endC >= N) return -1; // No letter immediately before/after word in its own direction if (dir === 'across') { if (col > 0 && grid[row][col - 1]) return -1; if (endC < N - 1 && grid[row][endC + 1]) return -1; } else { if (row > 0 && grid[row - 1][col]) return -1; if (endR < N - 1 && grid[endR + 1][col]) return -1; } let intersections = 0; for (let i = 0; i < word.length; i++) { const r = dir === 'across' ? row : row + i; const c = dir === 'across' ? col + i : col; const ex = grid[r][c]; if (ex !== null) { if (ex !== word[i]) return -1; // letter conflict intersections++; } else { // Empty cell — perpendicular neighbours must be empty if (dir === 'across') { if (r > 0 && grid[r - 1][c]) return -1; if (r < N - 1 && grid[r + 1][c]) return -1; } else { if (c > 0 && grid[r][c - 1]) return -1; if (c < N - 1 && grid[r][c + 1]) return -1; } } } return intersections; } // Find all valid placements by iterating over already-placed words. // Only tries the direction PERPENDICULAR to each placed word — guaranteed crossing. function _cwFindPlacements(grid, word, placed, N, center) { const seen = new Set(); const candidates = []; const acrossCount = placed.filter(p => p.dir === 'across').length; const downCount = placed.length - acrossCount; for (const pw of placed) { const newDir = pw.dir === 'across' ? 'down' : 'across'; for (let wi = 0; wi < word.length; wi++) { for (let pi = 0; pi < pw.word.length; pi++) { if (word[wi] !== pw.word[pi]) continue; // Intersection cell on the grid const ir = pw.dir === 'across' ? pw.row : pw.row + pi; const ic = pw.dir === 'across' ? pw.col + pi : pw.col; // Start of new word so that letter wi lands at (ir, ic) const r = newDir === 'across' ? ir : ir - wi; const c = newDir === 'across' ? ic - wi : ic; const key = `${r},${c},${newDir}`; if (seen.has(key)) continue; seen.add(key); const intersections = _cwCheck(grid, word, r, c, newDir, N); if (intersections < 1) continue; // Weighted score (MichaelWehar 70/15/10/5 split) const maxDist = center * Math.SQRT2; const dist = Math.hypot(r - center, c - center); const conn = intersections / (word.length / 2); const cen = 1 - dist / maxDist; const oBal = newDir === 'down' ? (acrossCount >= downCount ? 0.1 : 0) : (downCount >= acrossCount ? 0.1 : 0); const len = word.length / N; const score = conn * 0.7 + cen * 0.15 + oBal * 0.1 + len * 0.05; candidates.push({ row: r, col: c, dir: newDir, intersections, score }); } } } return candidates; } function _buildAttempt(words, N) { const grid = Array.from({ length: N }, () => Array(N).fill(null)); const placed = []; const center = Math.floor(N / 2); // First word: placed across at center const first = words[0]; const r0 = center; const c0 = Math.max(1, center - Math.floor(first.word.length / 2)); if (c0 + first.word.length > N - 1) return { placed, grid }; _cwPlace(grid, first.word, r0, c0, 'across'); placed.push({ ...first, row: r0, col: c0, dir: 'across' }); const skipped = []; for (const pass of [words.slice(1), skipped]) { for (const w of pass) { if (placed.length >= CW_MAX_WORDS) break; const cands = _cwFindPlacements(grid, w.word, placed, N, center); if (!cands.length) { if (pass !== skipped) skipped.push(w); continue; } cands.sort((a, b) => b.score - a.score); // Pick randomly from top 3 for variety across attempts const pick = cands[Math.floor(Math.random() * Math.min(3, cands.length))]; _cwPlace(grid, w.word, pick.row, pick.col, pick.dir); placed.push({ ...w, row: pick.row, col: pick.col, dir: pick.dir }); } } return { placed, grid }; } function buildCrossword(wordList) { const N = CW_GRID; let best = null; // Baseline: longest words first const sorted = [...wordList].sort((a, b) => b.word.length - a.word.length); best = _buildAttempt(sorted, N); for (let a = 1; a < CW_ATTEMPTS; a++) { if (best.placed.length >= CW_MAX_WORDS) break; const shuffled = _shuffle(wordList); // Ensure a long word is near the front (better first placement) shuffled.sort((x, y) => (y.word.length >= 6 ? 1 : 0) - (x.word.length >= 6 ? 1 : 0)); const attempt = _buildAttempt(shuffled, N); if (attempt.placed.length > best.placed.length) best = attempt; } if (!best || best.placed.length < 3) return null; // Compact: trim empty rows/cols const { placed, grid } = best; let minR = N, maxR = 0, minC = N, maxC = 0; for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) if (grid[r][c]) { minR = Math.min(minR, r); maxR = Math.max(maxR, r); minC = Math.min(minC, c); maxC = Math.max(maxC, c); } const trimmed = []; for (let r = minR; r <= maxR; r++) trimmed.push(grid[r].slice(minC, maxC + 1)); const words = placed.map(p => ({ word: p.word, clue: p.clue, subjectName: p.subjectName, row: p.row - minR, col: p.col - minC, dir: p.dir, })); // Number words in reading order (top→bottom, left→right) words.sort((a, b) => a.row !== b.row ? a.row - b.row : a.col - b.col); const starts = new Map(); let num = 1; for (const w of words) { const key = `${w.row},${w.col}`; if (!starts.has(key)) starts.set(key, num++); w.num = starts.get(key); } return { grid: trimmed, words, across: words.filter(w => w.dir === 'across'), down: words.filter(w => w.dir === 'down') }; } /* ── GET /api/games/hangman/word?subject_slug=bio ─────────────────────── */ function hangmanWord(req, res) { const { subject_slug } = req.query; let row; if (subject_slug) { const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug); if (!subj) return res.status(404).json({ error: 'Subject not found' }); row = db.prepare(` SELECT t.id, t.name, s.name AS subject_name, s.slug AS subject_slug FROM topics t JOIN subjects s ON s.id = t.subject_id WHERE t.subject_id = ? AND length(t.name) >= 4 ORDER BY RANDOM() LIMIT 1 `).get(subj.id); } else { row = db.prepare(` SELECT t.id, t.name, s.name AS subject_name, s.slug AS subject_slug FROM topics t JOIN subjects s ON s.id = t.subject_id WHERE length(t.name) >= 4 ORDER BY RANDOM() LIMIT 1 `).get(); } if (!row) return res.status(404).json({ error: 'No topics found' }); res.json({ topicId: row.id, word: row.name.toUpperCase(), hint: row.subject_name, subjectSlug: row.subject_slug, }); } // Анти-фарм: 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 ─────────────────────────────────── */ function hangmanComplete(req, res) { const { won, errors } = req.body; if (typeof won !== 'boolean') return res.status(400).json({ error: 'won required' }); let xpGain = 0; if (won) { // 15 XP perfect, -2 per error, min 5 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) { try { awardXP(req.user.id, xpGain, 'hangman_win'); } catch (e) { console.error('[games] hangman XP:', e.message); } } res.json({ ok: true, xp: xpGain }); } /* ── GET /api/games/crossword/generate?subject_slug= ──────────────────── */ function crosswordGenerate(req, res) { const { subject_slug } = req.query; let rows; const base = ` SELECT t.id, t.name, s.name AS subject_name, (SELECT q.text FROM questions q WHERE q.topic_id = t.id ORDER BY RANDOM() LIMIT 1) AS clue FROM topics t JOIN subjects s ON s.id = t.subject_id WHERE length(t.name) BETWEEN 4 AND 12 AND t.name NOT LIKE '% %' AND t.name NOT GLOB '*[0-9]*' `; if (subject_slug) { const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug); if (!subj) return res.status(404).json({ error: 'Subject not found' }); rows = db.prepare(base + ' AND t.subject_id = ? ORDER BY RANDOM() LIMIT 50').all(subj.id); } else { rows = db.prepare(base + ' ORDER BY RANDOM() LIMIT 50').all(); } if (rows.length < 3) return res.status(404).json({ error: 'Not enough topics for a crossword' }); const wordList = rows .filter(r => /^[А-яЁёA-Za-z]+$/.test(r.name)) // only letters, no numbers/symbols .map(r => ({ word: r.name.toUpperCase(), clue: r.clue || r.subject_name, subjectName: r.subject_name, })); const crossword = buildCrossword(wordList); if (!crossword) return res.status(404).json({ error: 'Could not build crossword' }); res.json(crossword); } /* ── POST /api/games/crossword/complete ───────────────────────────────── */ function crosswordComplete(req, res) { const { completed, hintsUsed } = req.body; if (typeof completed !== 'boolean') return res.status(400).json({ error: 'completed required' }); let xpGain = 0; if (completed) { 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) { try { awardXP(req.user.id, xpGain, 'crossword_win'); } catch (e) { console.error('[games] crossword XP:', e.message); } } res.json({ ok: true, xp: xpGain }); } module.exports = { hangmanWord, hangmanComplete, crosswordGenerate, crosswordComplete };