feat(biochem): Фаза 5.3 — 3D-build challenge с проверкой структуры
biochemController.js: structuralMatch/canonicalHash (Morgan-подобный канонический хеш графа) — для build-задания с data.requireStructure проверяется связность против эталонной молекулы (molecule_id), а не только формула. Отличает изомеры: этанол != диметиловый эфир при одной формуле C2H6O. seed_biochem_challenges.js: +4 structure-build задания (CO2, этилен, этанол, уксусная кислота). biochem.html: сообщение об ошибке wrong_structure. Проверено на реальном коде против БД: этанол==этанол true, ==диметиловый эфир false. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user