diff --git a/backend/scripts/seed_biochem_challenges.js b/backend/scripts/seed_biochem_challenges.js index 52887f5..7d455b3 100644 --- a/backend/scripts/seed_biochem_challenges.js +++ b/backend/scripts/seed_biochem_challenges.js @@ -81,6 +81,25 @@ const CHALLENGES = [ { type: 'complete', difficulty: 1, xp: 40, title: 'Заверши: горение углерода', desc: 'Какой продукт образуется при избытке кислорода?', data: { equation: 'C + O₂ → ?', choices: ['CO2', 'CO', 'C2O', 'O3'], answer: 'CO2' } }, + + // ── build со структурной проверкой (3D-build, Фаза 5.3) ─────────────────── + // target_formula — в Hill-нотации (как считает контроллер); эталон по molecule_id. + { type: 'build', difficulty: 1, xp: 45, title: 'Собери: углекислый газ (структура)', target: 'CO2', + desc: 'Построй CO₂: углерод с двумя двойными связями к кислороду. Проверяется связность.', + hint: 'O=C=O — две двойные связи', + data: { requireStructure: true, molecule_id: 2 } }, + { type: 'build', difficulty: 2, xp: 55, title: 'Собери: этилен (структура)', target: 'C2H4', + desc: 'Построй этилен C₂H₄: двойная связь C=C и по два H на каждом углероде.', + hint: 'H₂C=CH₂ — двойная связь между углеродами', + data: { requireStructure: true, molecule_id: 12 } }, + { type: 'build', difficulty: 2, xp: 60, title: 'Собери: этанол (структура)', target: 'C2H6O', + desc: 'Построй этанол: скелет C–C–O, заполни валентности водородами. Важна именно связность (не диметиловый эфир!).', + hint: 'CH₃–CH₂–OH: цепочка C–C–O', + data: { requireStructure: true, molecule_id: 14 } }, + { type: 'build', difficulty: 3, xp: 70, title: 'Собери: уксусную кислоту (структура)', target: 'C2H4O2', + desc: 'Построй уксусную кислоту CH₃COOH: метил + карбоксильная группа (C=O и C–O–H).', + hint: 'CH₃–COOH: карбоксил −COOH', + data: { requireStructure: true, molecule_id: 15 } }, ]; let order = (getMaxOrder.get().m || 0); diff --git a/backend/src/controllers/biochemController.js b/backend/src/controllers/biochemController.js index 3d79053..d5ef6f1 100644 --- a/backend/src/controllers/biochemController.js +++ b/backend/src/controllers/biochemController.js @@ -26,6 +26,44 @@ function valencyIssues(atoms, bonds) { .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'), @@ -222,6 +260,18 @@ function solveChallenge(req, res) { 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}`); res.json({ ok: true, xp: challenge.xp_reward }); @@ -273,4 +323,6 @@ module.exports = { getElements, getMolecules, getMolecule, validate, getReactions, getChallenges, solveChallenge, getSaved, saveMolecule, deleteSaved, + // экспортируется для тестов структурной проверки + structuralMatch, canonicalHash, }; diff --git a/frontend/biochem.html b/frontend/biochem.html index eb94541..096e8c2 100644 --- a/frontend/biochem.html +++ b/frontend/biochem.html @@ -1550,6 +1550,8 @@ async function submitChallenge() { loadChallenges(); } catch(e) { if (e.data?.error === 'wrong_formula') LS.toast(`Неверно. Ожидается ${e.data.expected}, получено ${e.data.submitted}`, 'error'); + else if (e.data?.error === 'wrong_structure') LS.toast('Формула верна, но структура (связность) не та — проверь, как соединены атомы', 'error'); + else if (e.data?.error === 'valency_error') LS.toast('Есть ошибки валентности', 'error'); else LS.toast('Ошибка: ' + e.message, 'error'); } }