Files
Learn_System/backend/src/controllers/gamesController.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

311 lines
11 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 { 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,
});
}
/* ── 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) {
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) {
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 };