b67fac6407
- biochem-core.js dual-export (browser window.BIO + Node module.exports), без дублей - BIO.valency: подробные подсказки валентности (2.4), общие для редактора и сервера - services/chem.js: серверный анализ поверх того же ядра (analyze/validate) - POST /api/biochem/analyze (2.2); /validate переведён на ядро (+фикс формата связей) - api.js: LS.biochemAnalyze Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
397 lines
18 KiB
JavaScript
397 lines
18 KiB
JavaScript
'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,
|
|
};
|