'use strict'; const db = require('../db/db'); const { awardXP, checkAchievements } = require('./gamificationController'); const chem = require('../services/chem'); /* ── Helpers ─────────────────────────────────────────────────────────── */ const MAX_V = { H:1, C:4, N:3, O:2, P:5, S:6, Cl:1, Na:1, Ca:2, K:1, Mg:2, Fe:3, Br:1, I:1, F:1 }; function hillFormula(atoms) { const cnt = {}; for (const a of atoms) cnt[a.s] = (cnt[a.s] || 0) + 1; const parts = []; if (cnt.C) { parts.push('C' + (cnt.C > 1 ? cnt.C : '')); delete cnt.C; } if (cnt.H) { parts.push('H' + (cnt.H > 1 ? cnt.H : '')); delete cnt.H; } for (const el of Object.keys(cnt).sort()) parts.push(el + (cnt[el] > 1 ? cnt[el] : '')); return parts.join(''); } function valencyIssues(atoms, bonds) { const sums = {}; for (const b of bonds) { sums[b.f] = (sums[b.f] || 0) + b.o; sums[b.t] = (sums[b.t] || 0) + b.o; } return atoms .filter(a => (sums[a.id] || 0) > (MAX_V[a.s] ?? 4)) .map(a => ({ id: a.id, symbol: a.s, used: sums[a.id] || 0, max: MAX_V[a.s] ?? 4 })); } /* ── Structural (connectivity) match: Morgan-style canonical hash ────────── * Сравнивает связность двух молекул независимо от id/координат/нумерации. * Инвариант атома итеративно уточняется по соседям и порядкам связей; * каноническая подпись = отсортированные инварианты атомов + рёбер. */ function _bf(b) { return b.from != null ? b.from : b.f; } function _bt(b) { return b.to != null ? b.to : b.t; } function _bo(b) { return (b.order != null ? b.order : b.o) || 1; } function _hashStr(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36); } function canonicalHash(atoms, bonds) { const adj = {}; atoms.forEach(a => adj[a.id] = []); for (const b of bonds) { const f = _bf(b), t = _bt(b), o = _bo(b); if (adj[f] && adj[t]) { adj[f].push({ id: t, o }); adj[t].push({ id: f, o }); } } let inv = {}; atoms.forEach(a => inv[a.id] = a.s); for (let iter = 0; iter < atoms.length; iter++) { const next = {}; for (const a of atoms) { const neigh = adj[a.id].map(n => inv[n.id] + ':' + n.o).sort().join(','); next[a.id] = _hashStr(inv[a.id] + '|' + neigh); } inv = next; } const atomSig = atoms.map(a => inv[a.id]).sort().join(';'); const edgeSig = bonds.map(b => [inv[_bf(b)], inv[_bt(b)]].sort().join('-') + '#' + _bo(b)).sort().join(','); return atomSig + '||' + edgeSig; } function structuralMatch(a1, b1, a2, b2) { if (!Array.isArray(a1) || !Array.isArray(a2)) return false; if (a1.length !== a2.length || (b1 || []).length !== (b2 || []).length) return false; return canonicalHash(a1, b1 || []) === canonicalHash(a2, b2 || []); } /* ── Prepared statements ─────────────────────────────────────────────── */ const stmts = { getElements: db.prepare('SELECT * FROM bio_elements ORDER BY radius ASC'), getMolecules: db.prepare("SELECT id,formula,name_ru,name_lat,category,difficulty,description,topic_tags,atoms_json,bonds_json FROM bio_molecules WHERE is_library=1 ORDER BY difficulty,name_ru"), getMolCat: db.prepare("SELECT id,formula,name_ru,name_lat,category,difficulty,description,topic_tags,atoms_json,bonds_json FROM bio_molecules WHERE is_library=1 AND category=? ORDER BY difficulty,name_ru"), getMolSearch: db.prepare("SELECT id,formula,name_ru,name_lat,category,difficulty,description,topic_tags,atoms_json,bonds_json FROM bio_molecules WHERE is_library=1 AND (name_ru LIKE ? OR formula LIKE ?) ORDER BY difficulty,name_ru"), getMolById: db.prepare('SELECT * FROM bio_molecules WHERE id=?'), getMolByFormula:db.prepare('SELECT id,name_ru,name_lat,category,description,topic_tags FROM bio_molecules WHERE formula=? LIMIT 1'), getReactions: db.prepare('SELECT * FROM bio_reactions ORDER BY name_ru'), getChallenges: db.prepare('SELECT * FROM bio_challenges ORDER BY difficulty,order_n'), getChallenge: db.prepare('SELECT * FROM bio_challenges WHERE id=?'), checkDone: db.prepare('SELECT 1 FROM bio_user_challenges WHERE user_id=? AND challenge_id=?'), markDone: db.prepare('INSERT OR IGNORE INTO bio_user_challenges (user_id,challenge_id) VALUES (?,?)'), getDoneIds: db.prepare('SELECT challenge_id FROM bio_user_challenges WHERE user_id=?'), getSaved: db.prepare('SELECT bm.*, bmo.name_ru AS mol_name FROM bio_user_molecules bm LEFT JOIN bio_molecules bmo ON bmo.id=bm.molecule_id WHERE bm.user_id=? ORDER BY bm.created_at DESC'), saveMol: db.prepare('INSERT INTO bio_user_molecules (user_id,molecule_id,name,formula,atoms_json,bonds_json) VALUES (?,?,?,?,?,?)'), deleteSaved: db.prepare('DELETE FROM bio_user_molecules WHERE id=? AND user_id=?'), }; /* ── GET /api/biochem/elements ───────────────────────────────────────── */ function getElements(_req, res) { res.json(stmts.getElements.all()); } /* ── GET /api/biochem/molecules ──────────────────────────────────────── */ function getMolecules(req, res) { const { cat, q } = req.query; let rows; if (q) { const like = `%${q}%`; rows = stmts.getMolSearch.all(like, like); } else if (cat) { rows = stmts.getMolCat.all(cat); } else { rows = stmts.getMolecules.all(); } rows = rows.map(r => ({ ...r, topic_tags: tryParse(r.topic_tags, []), atoms_json: tryParse(r.atoms_json, []), bonds_json: tryParse(r.bonds_json, []), })); res.json(rows); } /* ── GET /api/biochem/molecules/:id ─────────────────────────────────── */ function getMolecule(req, res) { const mol = stmts.getMolById.get(req.params.id); if (!mol) return res.status(404).json({ error: 'Not found' }); res.json({ ...mol, atoms_json: tryParse(mol.atoms_json, []), bonds_json: tryParse(mol.bonds_json, []), topic_tags: tryParse(mol.topic_tags, []), }); } /* ── POST /api/biochem/validate ─────────────────────────────────────── */ function validate(req, res) { const { atoms, bonds } = req.body; if (!Array.isArray(atoms) || !Array.isArray(bonds)) return res.status(400).json({ error: 'atoms[] and bonds[] required' }); if (atoms.length === 0) return res.json({ valid: false, formula: '', issues: [] }); // Единое химическое ядро (Фаза 2.1): формула + валентность с подсказками (2.4) const { valid, formula, issues } = chem.validate(atoms, bonds); const known = valid ? stmts.getMolByFormula.get(formula) : null; res.json({ valid, formula, issues, known: known || null }); } /* ── POST /api/biochem/analyze — полный химический анализ структуры (2.2) ─ */ function analyze(req, res) { const { atoms, bonds } = req.body || {}; if (!Array.isArray(atoms)) return res.status(400).json({ error: 'atoms[] обязателен' }); if (atoms.length === 0) return res.json({ formula: '', mass: 0, dbe: null, valency: [] }); try { res.json(chem.analyze(atoms, Array.isArray(bonds) ? bonds : [])); } catch (e) { res.status(500).json({ error: e.message }); } } /* ── GET /api/biochem/reactions ─────────────────────────────────────── */ function getReactions(_req, res) { const rows = stmts.getReactions.all().map(r => ({ ...r, reactant_ids: tryParse(r.reactant_ids, []), product_ids: tryParse(r.product_ids, []), topic_tags: tryParse(r.topic_tags, []), })); res.json(rows); } /* ── GET /api/biochem/challenges ────────────────────────────────────── */ function getChallenges(req, res) { const challenges = stmts.getChallenges.all(); const doneSet = new Set(stmts.getDoneIds.all(req.user.id).map(r => r.challenge_id)); res.json(challenges.map(c => ({ ...c, data_json: tryParse(c.data_json, null), done: doneSet.has(c.id), }))); } /* ── POST /api/biochem/challenges/:id/solve ─────────────────────────── */ function solveChallenge(req, res) { const challenge = stmts.getChallenge.get(req.params.id); if (!challenge) return res.status(404).json({ error: 'Challenge not found' }); if (stmts.checkDone.get(req.user.id, challenge.id)) return res.status(400).json({ error: 'already_completed' }); const type = challenge.type || 'build'; /* ── identify: shown structure → pick name ── */ if (type === 'identify') { const { answer } = req.body; if (typeof answer !== 'string' || !answer) return res.status(400).json({ error: 'answer required' }); const mol = stmts.getMolByFormula.get(challenge.target_formula); if (!mol || answer !== mol.name_ru) return res.status(400).json({ error: 'wrong_answer' }); stmts.markDone.run(req.user.id, challenge.id); awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`); checkAchievements(req.user.id); return res.json({ ok: true, xp: challenge.xp_reward }); } /* ── formula: shown name → pick formula ── */ if (type === 'formula') { const { answer } = req.body; if (typeof answer !== 'string' || !answer) return res.status(400).json({ error: 'answer required' }); if (answer !== challenge.target_formula) return res.status(400).json({ error: 'wrong_answer' }); stmts.markDone.run(req.user.id, challenge.id); awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`); checkAchievements(req.user.id); return res.json({ ok: true, xp: challenge.xp_reward }); } /* ── classify: shown molecule → pick class ── */ if (type === 'classify') { const { answer } = req.body; if (typeof answer !== 'string' || !answer) return res.status(400).json({ error: 'answer required' }); const data = tryParse(challenge.data_json, {}); if (answer !== data.answer) return res.status(400).json({ error: 'wrong_answer' }); stmts.markDone.run(req.user.id, challenge.id); awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`); checkAchievements(req.user.id); return res.json({ ok: true, xp: challenge.xp_reward }); } /* ── complete: shown partial reaction → pick missing component ── */ if (type === 'complete') { const { answer } = req.body; if (typeof answer !== 'string' || !answer) return res.status(400).json({ error: 'answer required' }); const data = tryParse(challenge.data_json, {}); if (answer !== data.answer) return res.status(400).json({ error: 'wrong_answer' }); stmts.markDone.run(req.user.id, challenge.id); awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`); checkAchievements(req.user.id); return res.json({ ok: true, xp: challenge.xp_reward }); } /* ── balance: fill coefficients to balance equation ── */ if (type === 'balance') { const { coefficients } = req.body; const data = tryParse(challenge.data_json, {}); const expected = data.coefficients || []; if (!Array.isArray(coefficients) || coefficients.length !== expected.length || !coefficients.every((c, i) => parseInt(c) === expected[i])) return res.status(400).json({ error: 'wrong_answer' }); stmts.markDone.run(req.user.id, challenge.id); awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`); checkAchievements(req.user.id); return res.json({ ok: true, xp: challenge.xp_reward }); } /* ── match: pair left items with right items ── */ if (type === 'match') { const { pairs } = req.body; const data = tryParse(challenge.data_json, {}); const answerMap = new Map((data.pairs || []).map(p => [p.left, p.right])); if (!Array.isArray(pairs) || pairs.length !== answerMap.size || !pairs.every(p => answerMap.get(p.left) === p.right)) return res.status(400).json({ error: 'wrong_answer' }); stmts.markDone.run(req.user.id, challenge.id); awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`); checkAchievements(req.user.id); return res.json({ ok: true, xp: challenge.xp_reward }); } /* ── build (default): draw the molecule ── */ const { atoms, bonds } = req.body; if (!Array.isArray(atoms) || !Array.isArray(bonds)) return res.status(400).json({ error: 'atoms[] and bonds[] required' }); const formula = hillFormula(atoms); if (formula !== challenge.target_formula) return res.status(400).json({ error: 'wrong_formula', submitted: formula, expected: challenge.target_formula }); const issues = valencyIssues(atoms, bonds); if (issues.length > 0) return res.status(400).json({ error: 'valency_error', issues }); // 3D-build: помимо формулы проверяем СТРУКТУРУ (связность) против эталона const bdata = tryParse(challenge.data_json, {}); if (bdata && bdata.requireStructure && bdata.molecule_id) { const ref = stmts.getMolById.get(bdata.molecule_id); if (ref) { const refAtoms = tryParse(ref.atoms_json, []); const refBonds = tryParse(ref.bonds_json, []); if (!structuralMatch(atoms, bonds, refAtoms, refBonds)) return res.status(400).json({ error: 'wrong_structure' }); } } stmts.markDone.run(req.user.id, challenge.id); awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`); checkAchievements(req.user.id); res.json({ ok: true, xp: challenge.xp_reward }); } /* ── GET /api/biochem/saved ─────────────────────────────────────────── */ function getSaved(req, res) { const rows = stmts.getSaved.all(req.user.id).map(r => ({ ...r, atoms_json: tryParse(r.atoms_json, []), bonds_json: tryParse(r.bonds_json, []), })); res.json(rows); } /* ── POST /api/biochem/saved ────────────────────────────────────────── */ function saveMolecule(req, res) { const { atoms, bonds, name } = req.body; if (!Array.isArray(atoms) || !Array.isArray(bonds) || atoms.length === 0) return res.status(400).json({ error: 'atoms[] and bonds[] required' }); const formula = hillFormula(atoms); const known = stmts.getMolByFormula.get(formula); const id = stmts.saveMol.run( req.user.id, known?.id ?? null, name?.trim() || null, formula, JSON.stringify(atoms), JSON.stringify(bonds), ).lastInsertRowid; checkAchievements(req.user.id); // bc_first_molecule res.status(201).json({ id, formula, known: known || null }); } /* ── DELETE /api/biochem/saved/:id ──────────────────────────────────── */ function deleteSaved(req, res) { const info = stmts.deleteSaved.run(req.params.id, req.user.id); if (info.changes === 0) return res.status(404).json({ error: 'Not found' }); res.json({ ok: true }); } /* ── GET /api/biochem/pathways — все пути из БД (карта slug → данные) ──── */ const stmtsPathways = db.prepare('SELECT slug, data_json FROM bio_pathways ORDER BY ord'); function getPathways(_req, res) { const out = {}; for (const r of stmtsPathways.all()) out[r.slug] = tryParse(r.data_json, {}); res.json(out); } /* ── Прогресс прохождения путей (Learn-режим) ────────────────────────── */ const PATHWAY_XP = 80; const stmtsPath = { getProg: db.prepare('SELECT pathway, step, completed FROM bio_user_pathway WHERE user_id=?'), wasDone: db.prepare('SELECT completed FROM bio_user_pathway WHERE user_id=? AND pathway=?'), upsert: db.prepare(`INSERT INTO bio_user_pathway (user_id, pathway, step, completed, updated_at) VALUES (?, ?, ?, ?, datetime('now')) ON CONFLICT(user_id, pathway) DO UPDATE SET step = excluded.step, completed = MAX(completed, excluded.completed), updated_at = datetime('now')`), }; /* ── GET /api/biochem/pathways/progress ──────────────────────────────── */ function getPathwayProgress(req, res) { const out = {}; for (const r of stmtsPath.getProg.all(req.user.id)) out[r.pathway] = { step: r.step, completed: !!r.completed }; res.json(out); } /* ── POST /api/biochem/pathways/progress ─────────────────────────────── */ function savePathwayProgress(req, res) { const { pathway, step, completed } = req.body; if (typeof pathway !== 'string' || !pathway) return res.status(400).json({ error: 'pathway required' }); const prev = stmtsPath.wasDone.get(req.user.id, pathway); const firstCompletion = !!completed && !(prev && prev.completed); stmtsPath.upsert.run(req.user.id, pathway, Math.max(0, parseInt(step) || 0), completed ? 1 : 0); let xp = 0; if (firstCompletion) { xp = PATHWAY_XP; awardXP(req.user.id, xp, `biochem_pathway:${pathway}`); checkAchievements(req.user.id); } res.json({ ok: true, xp }); } /* ── util ────────────────────────────────────────────────────────────── */ function tryParse(v, fallback) { if (!v) return fallback; try { return JSON.parse(v); } catch { return fallback; } } module.exports = { getElements, getMolecules, getMolecule, validate, analyze, getReactions, getChallenges, solveChallenge, getSaved, saveMolecule, deleteSaved, getPathways, getPathwayProgress, savePathwayProgress, // экспортируется для тестов структурной проверки structuralMatch, canonicalHash, };