be4d43105e
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>
311 lines
11 KiB
JavaScript
311 lines
11 KiB
JavaScript
'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 };
|