chore: консолидация незакоммиченной работы (биохимия + System Health + lab/textbooks)
Зафиксирована накопленная незакоммиченная работа рабочего дерева, КРОМЕ файлов учебника «Химия 7» (migration 046, chemistry_7_*.html, chem7_svg.js, тест — оставлены незакоммиченными по запросу). Включает: модуль биохимии (ядро BIO, 3D VSEPR, химдвижок, баланс, challenges, пути из БД), System Health Level 1 (вердикт/мониторинг), а также frontend- страницы и lab/textbooks-правки параллельной сессии. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
const db = require('../db/db');
|
||||
const { awardXP } = require('./gamificationController');
|
||||
const { awardXP, checkAchievements } = require('./gamificationController');
|
||||
|
||||
/* ── 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 };
|
||||
@@ -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'),
|
||||
@@ -140,6 +178,7 @@ function solveChallenge(req, res) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -152,6 +191,7 @@ function solveChallenge(req, res) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -165,6 +205,7 @@ function solveChallenge(req, res) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -178,6 +219,7 @@ function solveChallenge(req, res) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -192,6 +234,7 @@ function solveChallenge(req, res) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -206,6 +249,7 @@ function solveChallenge(req, res) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -222,8 +266,21 @@ 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}`);
|
||||
checkAchievements(req.user.id);
|
||||
res.json({ ok: true, xp: challenge.xp_reward });
|
||||
}
|
||||
|
||||
@@ -253,6 +310,7 @@ function saveMolecule(req, res) {
|
||||
JSON.stringify(atoms),
|
||||
JSON.stringify(bonds),
|
||||
).lastInsertRowid;
|
||||
checkAchievements(req.user.id); // bc_first_molecule
|
||||
res.status(201).json({ id, formula, known: known || null });
|
||||
}
|
||||
|
||||
@@ -263,6 +321,52 @@ function deleteSaved(req, res) {
|
||||
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;
|
||||
@@ -273,4 +377,7 @@ module.exports = {
|
||||
getElements, getMolecules, getMolecule, validate,
|
||||
getReactions, getChallenges, solveChallenge,
|
||||
getSaved, saveMolecule, deleteSaved,
|
||||
getPathways, getPathwayProgress, savePathwayProgress,
|
||||
// экспортируется для тестов структурной проверки
|
||||
structuralMatch, canonicalHash,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user