Files
Learn_System/backend/src/db/legacy-migrate.js
T
Maxim Dolgolyov 2fd7f6a463 refactor: switch to versioned migrations runner (phases 2+3)
- migrate.js → legacy-migrate.js (kept for rollback, delete 2026-07-01)
- tests/setup.js now uses migrations-runner.run() on fresh temp DB
- npm run migrate → versioned runner (was legacy init-every-start)
- npm run migrate:legacy → legacy-migrate.js (emergency rollback only)

After `npm run migrate:bootstrap` on prod:
  npm run migrate → "Nothing to apply — schema is up to date"

All 32 previously-passing tests continue to pass.
Pre-existing 3 auth.test.js failures (rate-limiter shared state) unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:50:40 +03:00

2989 lines
176 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// LEGACY: monolithic init-on-every-start migration. Do not use directly.
// Schema is now managed via migrations-runner.js + migrations/NNN_*.sql
// New schema changes → add migrations/NNN_description.sql, run `npm run migrate`
// This file kept for rollback reference. Delete after 2026-07-01 if stable.
const db = require('./db');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'student'
CHECK (role IN ('student', 'teacher', 'admin', 'free_student')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT
);
CREATE TABLE IF NOT EXISTS subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
icon TEXT
);
INSERT OR IGNORE INTO subjects (slug, name, icon) VALUES
('bio', 'Биология', 'dna'),
('chem', 'Химия', 'atom'),
('math', 'Математика', 'compass'),
('phys', 'Физика', 'zap');
CREATE TABLE IF NOT EXISTS topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_id INTEGER NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
order_index INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_id INTEGER NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
topic_id INTEGER REFERENCES topics(id) ON DELETE SET NULL,
text TEXT NOT NULL,
difficulty INTEGER NOT NULL DEFAULT 1 CHECK (difficulty BETWEEN 1 AND 3),
year INTEGER,
explanation TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_correct INTEGER NOT NULL DEFAULT 0,
order_index INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS test_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_id INTEGER REFERENCES subjects(id) ON DELETE SET NULL,
mode TEXT NOT NULL DEFAULT 'exam'
CHECK (mode IN ('exam', 'practice', 'topic', 'random')),
total INTEGER NOT NULL,
score INTEGER,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress', 'completed', 'abandoned')),
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT
);
CREATE TABLE IF NOT EXISTS session_questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES test_sessions(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id),
order_index INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES test_sessions(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id),
chosen_option_id INTEGER REFERENCES options(id),
is_correct INTEGER,
time_spent_sec INTEGER,
answered_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (session_id, question_id)
);
CREATE INDEX IF NOT EXISTS idx_questions_subject ON questions(subject_id);
CREATE INDEX IF NOT EXISTS idx_questions_topic ON questions(topic_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON test_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_answers_session ON user_answers(session_id);
-- ── Классы ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
teacher_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invite_code TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS class_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (class_id, user_id)
);
-- ── Задания ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
title TEXT NOT NULL,
subject_slug TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'exam',
count INTEGER NOT NULL DEFAULT 25,
topic_id INTEGER REFERENCES topics(id) ON DELETE SET NULL,
deadline TEXT,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS assignment_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
assignment_id INTEGER NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id INTEGER REFERENCES test_sessions(id) ON DELETE SET NULL,
UNIQUE (assignment_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_class_members_class ON class_members(class_id);
CREATE INDEX IF NOT EXISTS idx_class_members_user ON class_members(user_id);
CREATE INDEX IF NOT EXISTS idx_assignments_class ON assignments(class_id);
CREATE INDEX IF NOT EXISTS idx_assign_sess_assign ON assignment_sessions(assignment_id);
`);
console.log('✓ Migration complete. Database: learnspace.db');
/* ── Incremental columns (safe to run multiple times) ─────────────────── */
const incremental = [
"ALTER TABLE questions ADD COLUMN type TEXT NOT NULL DEFAULT 'single'",
"ALTER TABLE questions ADD COLUMN correct_text TEXT",
"ALTER TABLE user_answers ADD COLUMN answer_text TEXT",
`CREATE TABLE IF NOT EXISTS tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
subject_slug TEXT NOT NULL,
description TEXT,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS test_questions (
test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
order_index INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (test_id, question_id)
)`,
'ALTER TABLE assignments ADD COLUMN test_id INTEGER REFERENCES tests(id) ON DELETE SET NULL',
"ALTER TABLE tests ADD COLUMN show_answers INTEGER NOT NULL DEFAULT 1",
`CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
original_name TEXT NOT NULL,
stored_name TEXT NOT NULL UNIQUE,
mimetype TEXT,
size INTEGER,
subject_slug TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
uploaded_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
'CREATE INDEX IF NOT EXISTS idx_files_subject ON files(subject_slug)',
'CREATE INDEX IF NOT EXISTS idx_files_uploader ON files(uploaded_by)',
`CREATE TABLE IF NOT EXISTS file_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('class', 'user')),
target_id INTEGER NOT NULL,
UNIQUE (file_id, type, target_id)
)`,
'CREATE INDEX IF NOT EXISTS idx_file_access_file ON file_access(file_id)',
'ALTER TABLE assignments ADD COLUMN file_id INTEGER REFERENCES files(id) ON DELETE SET NULL',
'ALTER TABLE questions ADD COLUMN image TEXT',
'ALTER TABLE assignment_sessions ADD COLUMN first_seen_at TEXT',
"ALTER TABLE subjects ADD COLUMN default_mode TEXT NOT NULL DEFAULT 'exam'",
"ALTER TABLE subjects ADD COLUMN default_count INTEGER NOT NULL DEFAULT 25",
"ALTER TABLE subjects ADD COLUMN default_test_id INTEGER REFERENCES tests(id) ON DELETE SET NULL",
"ALTER TABLE tests ADD COLUMN time_limit INTEGER",
`CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
"CREATE INDEX IF NOT EXISTS idx_announcements_class ON announcements(class_id)",
`CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'info',
message TEXT NOT NULL,
link TEXT,
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
"CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, is_read)",
"ALTER TABLE options ADD COLUMN match_pair TEXT",
"ALTER TABLE assignments ADD COLUMN is_homework INTEGER NOT NULL DEFAULT 0",
`CREATE TABLE IF NOT EXISTS assignment_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
subject_slug TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'exam',
count INTEGER NOT NULL DEFAULT 25,
topic_id INTEGER REFERENCES topics(id) ON DELETE SET NULL,
test_id INTEGER REFERENCES tests(id) ON DELETE SET NULL,
file_id INTEGER REFERENCES files(id) ON DELETE SET NULL,
is_homework INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
"CREATE INDEX IF NOT EXISTS idx_tpl_creator ON assignment_templates(created_by)",
`CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
"ALTER TABLE files ADD COLUMN folder_id INTEGER",
`CREATE TABLE IF NOT EXISTS folder_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('class', 'user')),
target_id INTEGER NOT NULL,
UNIQUE (folder_id, type, target_id)
)`,
'CREATE INDEX IF NOT EXISTS idx_folder_access_folder ON folder_access(folder_id)',
`CREATE TABLE IF NOT EXISTS role_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL CHECK (role IN ('teacher', 'student', 'free_student')),
permission TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
UNIQUE (role, permission)
)`,
`CREATE TABLE IF NOT EXISTS user_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
UNIQUE (user_id, permission)
)`,
'CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id)',
`CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
assignment_id INTEGER REFERENCES assignments(id) ON DELETE SET NULL,
student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
stored_name TEXT NOT NULL UNIQUE,
mimetype TEXT,
size INTEGER,
message TEXT,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','reviewed')),
teacher_note TEXT,
submitted_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
'CREATE INDEX IF NOT EXISTS idx_submissions_class ON submissions(class_id)',
'CREATE INDEX IF NOT EXISTS idx_submissions_student ON submissions(student_id)',
'CREATE INDEX IF NOT EXISTS idx_submissions_assignment ON submissions(assignment_id)',
"ALTER TABLE questions ADD COLUMN source_type TEXT NOT NULL DEFAULT 'базовый'",
'CREATE INDEX IF NOT EXISTS idx_questions_source ON questions(source_type)',
];
for (const sql of incremental) {
try { db.exec(sql); }
catch (e) { if (!e.message.includes('duplicate column') && !e.message.includes('already exists')) console.warn(' incremental migration note:', e.message); }
}
/* ── Deduplicate topics (safe to run multiple times) ─────────────────── */
try {
const dupes = db.prepare(
'SELECT id FROM topics WHERE id NOT IN (SELECT MIN(id) FROM topics GROUP BY subject_id, name)'
).all();
if (dupes.length) {
const getDupesWithInfo = db.prepare(`
SELECT t.id, (SELECT MIN(id) FROM topics t2 WHERE t2.subject_id = t.subject_id AND t2.name = t.name) AS canonical_id
FROM topics t
WHERE t.id NOT IN (SELECT MIN(id) FROM topics GROUP BY subject_id, name)
`).all();
const updateQ = db.prepare('UPDATE questions SET topic_id = ? WHERE topic_id = ?');
db.exec('BEGIN');
for (const d of getDupesWithInfo) updateQ.run(d.canonical_id, d.id);
db.exec('COMMIT');
db.prepare('DELETE FROM topics WHERE id NOT IN (SELECT MIN(id) FROM topics GROUP BY subject_id, name)').run();
console.log(`✓ Deduplicated ${dupes.length} duplicate topics`);
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_topics_uniq ON topics(subject_id, name)');
} catch (e) {
try { db.exec('ROLLBACK'); } catch {}
console.warn('Topics dedup skipped:', e.message);
}
/* ── Recreate assignments table: make class_id nullable, add user_id ────── */
try {
const cols = db.prepare('PRAGMA table_info(assignments)').all();
const classIdCol = cols.find(c => c.name === 'class_id');
const hasUserId = cols.some(c => c.name === 'user_id');
if (classIdCol && classIdCol.notnull === 1 && !hasUserId) {
const hasTid = cols.some(c => c.name === 'test_id');
db.exec('PRAGMA foreign_keys = OFF');
const hasFileId = cols.some(c => c.name === 'file_id');
const hasHomework = cols.some(c => c.name === 'is_homework');
const hasMaxAtt = cols.some(c => c.name === 'max_attempts');
db.exec(`
CREATE TABLE assignments_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER REFERENCES classes(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
subject_slug TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'exam',
count INTEGER NOT NULL DEFAULT 25,
topic_id INTEGER REFERENCES topics(id) ON DELETE SET NULL,
deadline TEXT,
created_by INTEGER NOT NULL REFERENCES users(id),
test_id INTEGER REFERENCES tests(id) ON DELETE SET NULL,
file_id INTEGER REFERENCES files(id) ON DELETE SET NULL,
is_homework INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Copy only columns that exist in the old table
const copyCols = ['id','class_id','title','subject_slug','mode','count','topic_id','deadline','created_by','created_at'];
if (hasTid) copyCols.push('test_id');
if (hasFileId) copyCols.push('file_id');
if (hasHomework) copyCols.push('is_homework');
if (hasMaxAtt) copyCols.push('max_attempts');
const colList = copyCols.join(',');
db.exec(`INSERT INTO assignments_v2 (${colList}) SELECT ${colList} FROM assignments`);
db.exec('DROP TABLE assignments');
db.exec('ALTER TABLE assignments_v2 RENAME TO assignments');
db.exec('CREATE INDEX IF NOT EXISTS idx_assignments_class ON assignments(class_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_assignments_user ON assignments(user_id)');
db.exec('PRAGMA foreign_keys = ON');
console.log('✓ assignments migrated: class_id now nullable, user_id added');
}
} catch (e) {
try { db.exec('PRAGMA foreign_keys = ON'); } catch {}
console.warn('Assignment migration skipped:', e.message);
}
/* ── Theory / Lessons module ─────────────────────────────────────────── */
const theoryMigrations = [
`CREATE TABLE IF NOT EXISTS courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_slug TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
cover_emoji TEXT NOT NULL DEFAULT '',
order_index INTEGER NOT NULL DEFAULT 0,
is_published INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
'CREATE INDEX IF NOT EXISTS idx_courses_subject ON courses(subject_slug)',
'CREATE INDEX IF NOT EXISTS idx_courses_creator ON courses(created_by)',
`CREATE TABLE IF NOT EXISTS lessons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title TEXT NOT NULL,
order_index INTEGER NOT NULL DEFAULT 0,
is_published INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
'CREATE INDEX IF NOT EXISTS idx_lessons_course ON lessons(course_id)',
`CREATE TABLE IF NOT EXISTS lesson_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'text'
CHECK (type IN ('heading','text','formula','image','quiz','sim','table','code','divider')),
order_index INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL DEFAULT '{}'
)`,
'CREATE INDEX IF NOT EXISTS idx_blocks_lesson ON lesson_blocks(lesson_id)',
`CREATE TABLE IF NOT EXISTS lesson_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
completed INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, lesson_id)
)`,
'CREATE INDEX IF NOT EXISTS idx_lprogress_user ON lesson_progress(user_id)',
'CREATE INDEX IF NOT EXISTS idx_lprogress_lesson ON lesson_progress(lesson_id)',
];
for (const sql of theoryMigrations) {
try { db.exec(sql); }
catch (e) { if (!e.message.includes('duplicate column') && !e.message.includes('already exists')) console.warn(' theory migration note:', e.message); }
}
console.log('✓ Theory/Lessons tables ready');
/* ── Theory v2: sections, notes, class_courses ───────────────────────── */
const theoryV2 = [
`CREATE TABLE IF NOT EXISTS course_sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title TEXT NOT NULL,
order_index INTEGER NOT NULL DEFAULT 0
)`,
'CREATE INDEX IF NOT EXISTS idx_sections_course ON course_sections(course_id)',
"ALTER TABLE lessons ADD COLUMN section_id INTEGER REFERENCES course_sections(id) ON DELETE SET NULL",
"ALTER TABLE lessons ADD COLUMN read_time INTEGER NOT NULL DEFAULT 0",
`CREATE TABLE IF NOT EXISTS lesson_notes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
text TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, lesson_id)
)`,
'CREATE INDEX IF NOT EXISTS idx_notes_user ON lesson_notes(user_id)',
'CREATE INDEX IF NOT EXISTS idx_notes_lesson ON lesson_notes(lesson_id)',
`CREATE TABLE IF NOT EXISTS class_courses (
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
deadline TEXT,
assigned_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (class_id, course_id)
)`,
'CREATE INDEX IF NOT EXISTS idx_cc_class ON class_courses(class_id)',
'CREATE INDEX IF NOT EXISTS idx_cc_course ON class_courses(course_id)',
];
for (const sql of theoryV2) {
try { db.exec(sql); }
catch (e) { if (!e.message.includes('duplicate column') && !e.message.includes('already exists')) console.warn(' theoryV2 migration note:', e.message); }
}
console.log('✓ Theory v2 tables ready');
/* ── Grading: add grade column to submissions ────────────────────────── */
try { db.exec('ALTER TABLE submissions ADD COLUMN grade INTEGER'); } catch {}
console.log('✓ Grading column ready');
/* ── lesson_blocks v2: extend type constraint (callout, video, flashcard) */
try {
const tblDef = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='lesson_blocks'").get();
if (tblDef && !tblDef.sql.includes('callout')) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`CREATE TABLE lesson_blocks_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'text'
CHECK (type IN ('heading','text','formula','image','quiz','sim',
'table','code','divider','callout','video','flashcard')),
order_index INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL DEFAULT '{}'
)`);
db.exec('INSERT INTO lesson_blocks_v2 SELECT * FROM lesson_blocks');
db.exec('DROP TABLE lesson_blocks');
db.exec('ALTER TABLE lesson_blocks_v2 RENAME TO lesson_blocks');
db.exec('CREATE INDEX IF NOT EXISTS idx_blocks_lesson ON lesson_blocks(lesson_id)');
db.exec('PRAGMA foreign_keys = ON');
console.log('✓ lesson_blocks: extended type constraint (callout/video/flashcard)');
}
} catch (e) {
try { db.exec('PRAGMA foreign_keys = ON'); } catch {}
console.warn('lesson_blocks migration skipped:', e.message);
}
/* ── Gamification: XP, Levels, Streaks, Achievements, Daily Goals ────── */
const gamificationMigrations = [
"ALTER TABLE users ADD COLUMN xp INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN level INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE users ADD COLUMN streak_current INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN streak_best INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN streak_date TEXT",
`CREATE TABLE IF NOT EXISTS xp_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
'CREATE INDEX IF NOT EXISTS idx_xp_log_user ON xp_log(user_id)',
'CREATE INDEX IF NOT EXISTS idx_xp_log_date ON xp_log(created_at)',
`CREATE TABLE IF NOT EXISTS achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT 'trophy',
category TEXT NOT NULL DEFAULT 'general',
description TEXT
)`,
`CREATE TABLE IF NOT EXISTS user_achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
achievement_id INTEGER NOT NULL REFERENCES achievements(id) ON DELETE CASCADE,
unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, achievement_id)
)`,
'CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id)',
`CREATE TABLE IF NOT EXISTS daily_goals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL,
tests_target INTEGER NOT NULL DEFAULT 3,
tests_done INTEGER NOT NULL DEFAULT 0,
xp_target INTEGER NOT NULL DEFAULT 200,
xp_earned INTEGER NOT NULL DEFAULT 0,
UNIQUE (user_id, date)
)`,
'CREATE INDEX IF NOT EXISTS idx_daily_goals_user ON daily_goals(user_id, date)',
`CREATE TABLE IF NOT EXISTS challenges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
week TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'tests',
target INTEGER NOT NULL DEFAULT 3,
progress INTEGER NOT NULL DEFAULT 0,
xp_reward INTEGER NOT NULL DEFAULT 100,
subject_slug TEXT,
topic_id INTEGER,
completed INTEGER NOT NULL DEFAULT 0,
claimed INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id, week, title)
)`,
'CREATE INDEX IF NOT EXISTS idx_challenges_user_week ON challenges(user_id, week)',
];
for (const sql of gamificationMigrations) {
try { db.exec(sql); }
catch (e) { if (!e.message.includes('duplicate column') && !e.message.includes('already exists')) console.warn(' gamification migration note:', e.message); }
}
// Add goal_tier column to users if missing
try { db.exec("ALTER TABLE users ADD COLUMN goal_tier TEXT DEFAULT 'medium'"); } catch {}
// Add avatar_frame column to users if missing
try { db.exec("ALTER TABLE users ADD COLUMN avatar_frame TEXT DEFAULT 'default'"); } catch {}
// Seed achievements
try {
const { seedAchievements } = require('../controllers/gamificationController');
seedAchievements();
} catch (e) { console.warn(' seedAchievements note:', e.message); }
console.log('✓ Gamification tables ready');
/* ── lesson_blocks v3: add interactive types (matching, fill-blank, ordering) */
try {
const tblDef = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='lesson_blocks'").get();
if (tblDef && !tblDef.sql.includes('matching')) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`CREATE TABLE lesson_blocks_v3 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'text'
CHECK (type IN ('heading','text','formula','image','quiz','sim',
'table','code','divider','callout','video','flashcard',
'matching','fill-blank','ordering')),
order_index INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL DEFAULT '{}'
)`);
db.exec('INSERT INTO lesson_blocks_v3 SELECT * FROM lesson_blocks');
db.exec('DROP TABLE lesson_blocks');
db.exec('ALTER TABLE lesson_blocks_v3 RENAME TO lesson_blocks');
db.exec('CREATE INDEX IF NOT EXISTS idx_blocks_lesson ON lesson_blocks(lesson_id)');
db.exec('PRAGMA foreign_keys = ON');
console.log('✓ lesson_blocks v3: interactive types added');
}
} catch (e) {
try { db.exec('PRAGMA foreign_keys = ON'); } catch {}
console.warn('lesson_blocks v3 migration skipped:', e.message);
}
/* ── lesson_blocks v4: full type set (accordion, timeline, diagram, alert, columns, geogebra, audio) */
try {
const tblDef4 = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='lesson_blocks'").get();
if (tblDef4 && !tblDef4.sql.includes('accordion')) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`CREATE TABLE lesson_blocks_v4 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'text'
CHECK (type IN ('heading','text','formula','image','quiz','sim',
'table','code','divider','callout','video','flashcard',
'matching','fill-blank','ordering',
'accordion','timeline','diagram','geogebra','audio','columns','alert')),
order_index INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL DEFAULT '{}'
)`);
db.exec('INSERT INTO lesson_blocks_v4 SELECT * FROM lesson_blocks');
db.exec('DROP TABLE lesson_blocks');
db.exec('ALTER TABLE lesson_blocks_v4 RENAME TO lesson_blocks');
db.exec('CREATE INDEX IF NOT EXISTS idx_blocks_lesson ON lesson_blocks(lesson_id)');
db.exec('PRAGMA foreign_keys = ON');
console.log('✓ lesson_blocks v4: rich content types added');
}
} catch (e) {
try { db.exec('PRAGMA foreign_keys = ON'); } catch {}
console.warn('lesson_blocks v4 migration skipped:', e.message);
}
/* ── Templates: course & lesson templates ──────────────────────────── */
const templateMigrations = [
`CREATE TABLE IF NOT EXISTS course_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'general',
subject_slug TEXT,
structure TEXT NOT NULL DEFAULT '{}',
is_public INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
'CREATE INDEX IF NOT EXISTS idx_ctpl_creator ON course_templates(created_by)',
'CREATE INDEX IF NOT EXISTS idx_ctpl_public ON course_templates(is_public)',
`CREATE TABLE IF NOT EXISTS lesson_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
subject_slug TEXT,
blocks TEXT NOT NULL DEFAULT '[]',
is_public INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
'CREATE INDEX IF NOT EXISTS idx_ltpl_creator ON lesson_templates(created_by)',
'CREATE INDEX IF NOT EXISTS idx_ltpl_public ON lesson_templates(is_public)',
];
for (const sql of templateMigrations) {
try { db.exec(sql); }
catch (e) { if (!e.message.includes('duplicate column') && !e.message.includes('already exists')) console.warn(' template migration note:', e.message); }
}
console.log('✓ Templates tables ready');
/* ── Shop: coins, items, purchases ─────────────────────────────────── */
try { db.exec("ALTER TABLE users ADD COLUMN coins INTEGER NOT NULL DEFAULT 0"); } catch {}
const shopMigrations = [
`CREATE TABLE IF NOT EXISTS shop_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'frame'
CHECK (type IN ('frame','theme','title','effect')),
category TEXT NOT NULL DEFAULT 'cosmetic',
price INTEGER NOT NULL DEFAULT 100,
data TEXT NOT NULL DEFAULT '{}',
icon TEXT NOT NULL DEFAULT 'star',
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS user_purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES shop_items(id) ON DELETE CASCADE,
purchased_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, item_id)
)`,
'CREATE INDEX IF NOT EXISTS idx_purchases_user ON user_purchases(user_id)',
];
for (const sql of shopMigrations) {
try { db.exec(sql); }
catch (e) { if (!e.message.includes('duplicate column') && !e.message.includes('already exists')) console.warn(' shop migration note:', e.message); }
}
// Seed default shop items
try {
const cnt = db.prepare('SELECT COUNT(*) as c FROM shop_items').get().c;
if (cnt === 0) {
const ins = db.prepare('INSERT INTO shop_items (name, description, type, category, price, data, icon) VALUES (?,?,?,?,?,?,?)');
const items = [
['Неоновая рамка', 'Яркая неоновая рамка профиля', 'frame', 'cosmetic', 200, '{"css":"box-shadow:0 0 15px #0ff,inset 0 0 15px #0ff;border-color:#0ff"}', 'sparkles'],
['Огненная рамка', 'Рамка с эффектом пламени', 'frame', 'cosmetic', 300, '{"css":"box-shadow:0 0 15px #f60,inset 0 0 10px #f60;border-color:#f60"}', 'flame'],
['Радужная рамка', 'Переливающаяся радужная рамка', 'frame', 'cosmetic', 500, '{"css":"border-image:linear-gradient(135deg,#f06,#0cf,#0f6,#ff0,#f06) 1;box-shadow:0 0 12px rgba(255,0,100,.4)"}', 'star'],
['Титул: Знаток', 'Отображается под именем в профиле', 'title', 'cosmetic', 150, '{"text":"Знаток","color":"#4CC9F0"}', 'crown'],
['Титул: Гений', 'Отображается под именем в профиле', 'title', 'cosmetic', 400, '{"text":"Гений","color":"#FFD166"}', 'brain'],
['Титул: Легенда', 'Отображается под именем в профиле', 'title', 'cosmetic', 800, '{"text":"Легенда","color":"#EF476F"}', 'trophy'],
['Эффект: Искры', 'Частицы-искры вокруг аватара', 'effect', 'cosmetic', 350, '{"effect":"sparkle"}', 'sparkles'],
['Эффект: Пульсация', 'Пульсирующее свечение аватара', 'effect', 'cosmetic', 200, '{"effect":"pulse"}', 'zap'],
['Эффект: Снежинки', 'Снежинки вокруг аватара', 'effect', 'cosmetic', 300, '{"effect":"snow"}', 'star'],
];
for (const it of items) ins.run(...it);
console.log('✓ Shop items seeded');
}
} catch (e) { console.warn('Shop seed skipped:', e.message); }
// Active cosmetics columns
try { db.exec("ALTER TABLE users ADD COLUMN active_title TEXT DEFAULT NULL"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN active_theme TEXT DEFAULT NULL"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN active_effect TEXT DEFAULT NULL"); } catch {}
console.log('✓ Shop tables ready');
/* ── Token version for JWT invalidation ── */
try { db.exec("ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0"); } catch {}
console.log('✓ Token version column ready');
/* ── Lab experiments counter ── */
try { db.exec("ALTER TABLE users ADD COLUMN lab_experiments INTEGER NOT NULL DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN lab_reactions INTEGER NOT NULL DEFAULT 0"); } catch {}
console.log('✓ Lab tracking columns ready');
/* ── Lesson comments ─────────────────────────────────────────────────── */
const commentMigrations = [
`CREATE TABLE IF NOT EXISTS lesson_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lesson_id INTEGER NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES lesson_comments(id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
'CREATE INDEX IF NOT EXISTS idx_lcomments_lesson ON lesson_comments(lesson_id)',
'CREATE INDEX IF NOT EXISTS idx_lcomments_user ON lesson_comments(user_id)',
'CREATE INDEX IF NOT EXISTS idx_lcomments_parent ON lesson_comments(parent_id)',
];
for (const sql of commentMigrations) {
try { db.exec(sql); }
catch (e) { if (!e.message.includes('duplicate column') && !e.message.includes('already exists')) console.warn(' comment migration note:', e.message); }
}
console.log('✓ Lesson comments table ready');
/* ── Bookmarks ───────────────────────────────────────────────────────── */
const bookmarkMigrations = [
`CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL CHECK (entity_type IN ('lesson','course','file','question')),
entity_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, entity_type, entity_id)
)`,
'CREATE INDEX IF NOT EXISTS idx_bookmarks_user ON bookmarks(user_id)',
];
for (const sql of bookmarkMigrations) {
try { db.exec(sql); }
catch (e) { if (!e.message.includes('duplicate column') && !e.message.includes('already exists')) console.warn(' bookmark migration note:', e.message); }
}
console.log('✓ Bookmarks table ready');
/* ── Submissions v2: expand status CHECK + add reviewed_at ────────────── */
try {
const tblDef = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='submissions'").get();
if (tblDef && !tblDef.sql.includes('revision')) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`CREATE TABLE submissions_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
assignment_id INTEGER REFERENCES assignments(id) ON DELETE SET NULL,
student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
stored_name TEXT NOT NULL UNIQUE,
mimetype TEXT,
size INTEGER,
message TEXT,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','reviewed','revision','resubmitted','accepted')),
teacher_note TEXT,
grade INTEGER,
reviewed_at TEXT,
submitted_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
db.exec(`INSERT INTO submissions_v2 (id,class_id,assignment_id,student_id,original_name,stored_name,mimetype,size,message,status,teacher_note,grade,submitted_at)
SELECT id,class_id,assignment_id,student_id,original_name,stored_name,mimetype,size,message,status,teacher_note,grade,submitted_at FROM submissions`);
db.exec('DROP TABLE submissions');
db.exec('ALTER TABLE submissions_v2 RENAME TO submissions');
db.exec('CREATE INDEX IF NOT EXISTS idx_submissions_class ON submissions(class_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_submissions_student ON submissions(student_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_submissions_assignment ON submissions(assignment_id)');
db.exec('PRAGMA foreign_keys = ON');
console.log('✓ Submissions v2: expanded statuses + reviewed_at');
}
} catch (e) {
try { db.exec('PRAGMA foreign_keys = ON'); } catch {}
console.warn('Submissions v2 migration skipped:', e.message);
}
/* ── Flashcards ─────────────────────────────────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS flashcard_decks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#9B5DE5',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS flashcard_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE,
front TEXT NOT NULL DEFAULT '',
back TEXT NOT NULL DEFAULT '',
order_idx INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS flashcard_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
card_id INTEGER NOT NULL REFERENCES flashcard_cards(id) ON DELETE CASCADE,
ease_factor REAL NOT NULL DEFAULT 2.5,
interval_days INTEGER NOT NULL DEFAULT 1,
repetitions INTEGER NOT NULL DEFAULT 0,
due_at TEXT NOT NULL DEFAULT (datetime('now')),
last_reviewed TEXT,
UNIQUE(user_id, card_id)
);
`);
/* ── Performance indexes ─────────────────────────────────────────────────── */
try { db.exec('CREATE INDEX IF NOT EXISTS idx_answers_sq ON user_answers(session_id, question_id)'); } catch {}
try { db.exec('CREATE INDEX IF NOT EXISTS idx_sq_session ON session_questions(session_id)'); } catch {}
try { db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_user_status ON test_sessions(user_id, status)'); } catch {}
console.log('✓ Performance indexes ready');
/* ── Test attempts: max_attempts on assignments ──────────────────────────── */
try { db.exec('ALTER TABLE assignments ADD COLUMN max_attempts INTEGER NOT NULL DEFAULT 0'); } catch {}
console.log('✓ max_attempts column ready');
/* ── assignment_sessions v2: allow multiple attempts (remove UNIQUE) ──────── */
try {
const cols = db.prepare('PRAGMA table_info(assignment_sessions)').all();
const hasAttemptNum = cols.some(c => c.name === 'attempt_num');
if (!hasAttemptNum) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`
CREATE TABLE assignment_sessions_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
assignment_id INTEGER NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id INTEGER REFERENCES test_sessions(id) ON DELETE SET NULL,
attempt_num INTEGER NOT NULL DEFAULT 1,
first_seen_at TEXT
)
`);
db.exec(`INSERT INTO assignment_sessions_v2 (id, assignment_id, user_id, session_id, first_seen_at)
SELECT id, assignment_id, user_id, session_id, first_seen_at FROM assignment_sessions`);
db.exec('DROP TABLE assignment_sessions');
db.exec('ALTER TABLE assignment_sessions_v2 RENAME TO assignment_sessions');
db.exec('CREATE INDEX IF NOT EXISTS idx_assign_sess_assign ON assignment_sessions(assignment_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_assign_sess_user ON assignment_sessions(user_id)');
db.exec('PRAGMA foreign_keys = ON');
console.log('✓ assignment_sessions v2: multiple attempts enabled');
}
} catch (e) {
try { db.exec('PRAGMA foreign_keys = ON'); } catch {}
console.warn('assignment_sessions v2 migration skipped:', e.message);
}
/* ── App settings ────────────────────────────────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
INSERT OR IGNORE INTO app_settings (key, value) VALUES
('sim_module_disabled', '0'),
('sim_disabled_ids', '[]'),
('feature_crossword_enabled', '1'),
('feature_hangman_enabled', '1'),
('feature_pet_enabled', '1'),
('feature_red_book_enabled', '1'),
('feature_collection_enabled', '1'),
('feature_flashcards_enabled', '1'),
('feature_knowledge_map_enabled', '1'),
('feature_board_enabled', '1'),
('feature_biochem_enabled', '1'),
('feature_live_quiz_enabled', '1'),
('feature_classroom_enabled', '1');
`);
/* ── Performance indexes ───────────────────────────────────────────────── */
db.exec(`
CREATE INDEX IF NOT EXISTS idx_test_sessions_status ON test_sessions(status);
CREATE INDEX IF NOT EXISTS idx_test_sessions_user_status ON test_sessions(user_id, status);
CREATE INDEX IF NOT EXISTS idx_assignments_deadline ON assignments(deadline);
CREATE INDEX IF NOT EXISTS idx_xp_log_user_created ON xp_log(user_id, created_at);
`);
console.log('✓ Performance indexes ready');
/* ── User ban flag ─────────────────────────────────────────────────────── */
try { db.exec("ALTER TABLE users ADD COLUMN is_banned INTEGER NOT NULL DEFAULT 0"); } catch {}
console.log('✓ User ban column ready');
/* ── Class cover icon ─────────────────────────────────────────────────── */
try { db.exec("ALTER TABLE classes ADD COLUMN cover_emoji TEXT NOT NULL DEFAULT ''"); } catch {}
console.log('✓ Class cover_emoji column ready');
/* ── Live Quiz ──────────────────────────────────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS live_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
teacher_id INTEGER NOT NULL REFERENCES users(id),
question_id INTEGER REFERENCES questions(id),
status TEXT NOT NULL DEFAULT 'waiting',
show_results INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
ended_at TEXT
);
CREATE TABLE IF NOT EXISTS live_answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
live_session_id INTEGER NOT NULL REFERENCES live_sessions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
option_id INTEGER REFERENCES options(id),
answer_text TEXT,
is_correct INTEGER,
answered_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(live_session_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_live_sessions_class ON live_sessions(class_id, status);
CREATE INDEX IF NOT EXISTS idx_live_answers_session ON live_answers(live_session_id);
`);
// Migration: add question_id to live_answers + recreate unique constraint
// (so history is preserved when teacher switches between questions in one session)
{
const cols = db.prepare('PRAGMA table_info(live_answers)').all();
const hasQid = cols.some(c => c.name === 'question_id');
if (!hasQid) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`
CREATE TABLE live_answers_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
live_session_id INTEGER NOT NULL REFERENCES live_sessions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
question_id INTEGER REFERENCES questions(id) ON DELETE SET NULL,
option_id INTEGER REFERENCES options(id),
answer_text TEXT,
is_correct INTEGER,
answered_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(live_session_id, user_id, question_id)
)
`);
// Migrate existing rows — old answers don't have question_id, set NULL
db.exec(`
INSERT INTO live_answers_v2 (id, live_session_id, user_id, option_id, answer_text, is_correct, answered_at)
SELECT id, live_session_id, user_id, option_id, answer_text, is_correct, answered_at FROM live_answers
`);
db.exec('DROP TABLE live_answers');
db.exec('ALTER TABLE live_answers_v2 RENAME TO live_answers');
db.exec('CREATE INDEX IF NOT EXISTS idx_live_answers_session ON live_answers(live_session_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_live_answers_question ON live_answers(live_session_id, question_id)');
db.exec('PRAGMA foreign_keys = ON');
console.log('✓ Migrated live_answers → added question_id');
}
}
console.log('✓ Live quiz tables ready');
/* ── Submission audit log ──────────────────────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS submission_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
submission_id INTEGER NOT NULL,
class_id INTEGER,
assignment_id INTEGER,
student_id INTEGER NOT NULL,
student_name TEXT,
original_name TEXT,
status TEXT,
grade INTEGER,
teacher_note TEXT,
submitted_at TEXT,
action TEXT NOT NULL DEFAULT 'deleted',
deleted_by INTEGER NOT NULL,
deleted_by_role TEXT,
deleted_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
console.log('✓ Submission audit log ready');
/* ── Красная книга РБ ───────────────────────────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS rb_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_ru TEXT NOT NULL,
name_lat TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT '🌿',
color TEXT NOT NULL DEFAULT '#16a34a'
);
CREATE TABLE IF NOT EXISTS rb_habitats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'forest',
description TEXT NOT NULL DEFAULT '',
sound_file TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS rb_species (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES rb_groups(id),
habitat_id INTEGER REFERENCES rb_habitats(id),
name_ru TEXT NOT NULL,
name_be TEXT NOT NULL DEFAULT '',
name_lat TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'VU'
CHECK (category IN ('CR','EN','VU','NT','LC')),
by_category TEXT NOT NULL DEFAULT 'III',
description TEXT NOT NULL DEFAULT '',
interesting_fact TEXT NOT NULL DEFAULT '',
threats TEXT NOT NULL DEFAULT '[]',
conservation TEXT NOT NULL DEFAULT '',
where_to_see TEXT NOT NULL DEFAULT '',
photo_url TEXT NOT NULL DEFAULT '',
model_type TEXT NOT NULL DEFAULT 'silhouette'
CHECK (model_type IN ('procedural','silhouette','none')),
population_trend TEXT NOT NULL DEFAULT '[]',
biomass_kg REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS rb_species_regions (
species_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE,
region_code TEXT NOT NULL
CHECK (region_code IN ('brest','vitebsk','gomel','grodno','minsk','mogilev')),
PRIMARY KEY (species_id, region_code)
);
CREATE TABLE IF NOT EXISTS rb_food_web (
predator_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE,
prey_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE,
strength REAL NOT NULL DEFAULT 0.5,
PRIMARY KEY (predator_id, prey_id)
);
CREATE TABLE IF NOT EXISTS rb_population_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
species_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
count_estimate INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS rb_user_collection (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
species_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE,
unlock_method TEXT NOT NULL DEFAULT 'explore',
notes TEXT NOT NULL DEFAULT '',
unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, species_id)
);
CREATE TABLE IF NOT EXISTS rb_quests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
species_ids TEXT NOT NULL DEFAULT '[]',
xp_reward INTEGER NOT NULL DEFAULT 150,
badge_slug TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS rb_user_quests (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
quest_id INTEGER NOT NULL REFERENCES rb_quests(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','completed')),
progress TEXT NOT NULL DEFAULT '{}',
completed_at TEXT,
PRIMARY KEY (user_id, quest_id)
);
CREATE TABLE IF NOT EXISTS rb_sightings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
species_id INTEGER NOT NULL REFERENCES rb_species(id) ON DELETE CASCADE,
region_code TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
photo_url TEXT NOT NULL DEFAULT '',
confirmed_by_teacher INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_rb_species_group ON rb_species(group_id);
CREATE INDEX IF NOT EXISTS idx_rb_species_category ON rb_species(category);
CREATE INDEX IF NOT EXISTS idx_rb_collection_user ON rb_user_collection(user_id);
CREATE INDEX IF NOT EXISTS idx_rb_sightings_user ON rb_sightings(user_id);
CREATE INDEX IF NOT EXISTS idx_rb_popdata_species ON rb_population_data(species_id);
`);
console.log('✓ Red Book tables ready');
// Red Book: add season_active column if not exists
try { db.exec("ALTER TABLE rb_species ADD COLUMN season_active TEXT NOT NULL DEFAULT ''"); } catch {}
// Red Book: add name_be_short column if not exists
try { db.exec("ALTER TABLE rb_species ADD COLUMN name_be_short TEXT NOT NULL DEFAULT ''"); } catch {}
// Classes: per-class feature toggles (JSON)
try { db.exec("ALTER TABLE classes ADD COLUMN features TEXT"); } catch {}
console.log('✓ Classes features column ready');
/* ── Biochemistry / Molecular Constructor ────────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS bio_elements (
symbol TEXT PRIMARY KEY,
name_ru TEXT NOT NULL,
valency_max INTEGER NOT NULL DEFAULT 1,
valency_opts TEXT NOT NULL DEFAULT '[1]',
color TEXT NOT NULL DEFAULT '#888888',
radius INTEGER NOT NULL DEFAULT 20,
mass REAL NOT NULL DEFAULT 1.0
);
CREATE TABLE IF NOT EXISTS bio_molecules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formula TEXT NOT NULL,
name_ru TEXT NOT NULL,
name_lat TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'inorganic',
difficulty INTEGER NOT NULL DEFAULT 1,
description TEXT NOT NULL DEFAULT '',
atoms_json TEXT NOT NULL DEFAULT '[]',
bonds_json TEXT NOT NULL DEFAULT '[]',
is_library INTEGER NOT NULL DEFAULT 1,
topic_tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS bio_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
equation TEXT NOT NULL,
name_ru TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'synthesis',
description TEXT NOT NULL DEFAULT '',
reactant_ids TEXT NOT NULL DEFAULT '[]',
product_ids TEXT NOT NULL DEFAULT '[]',
conditions TEXT DEFAULT NULL,
energy_kj REAL DEFAULT NULL,
topic_tags TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS bio_challenges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'build',
target_formula TEXT NOT NULL DEFAULT '',
hint TEXT DEFAULT NULL,
xp_reward INTEGER NOT NULL DEFAULT 50,
difficulty INTEGER NOT NULL DEFAULT 1,
topic_tag TEXT DEFAULT NULL,
order_n INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS bio_user_molecules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
molecule_id INTEGER REFERENCES bio_molecules(id),
name TEXT DEFAULT NULL,
formula TEXT NOT NULL,
atoms_json TEXT NOT NULL,
bonds_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS bio_user_challenges (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
challenge_id INTEGER NOT NULL REFERENCES bio_challenges(id) ON DELETE CASCADE,
completed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, challenge_id)
);
CREATE INDEX IF NOT EXISTS idx_bio_mol_formula ON bio_molecules(formula);
CREATE INDEX IF NOT EXISTS idx_bio_mol_cat ON bio_molecules(category);
CREATE INDEX IF NOT EXISTS idx_bio_user_mol ON bio_user_molecules(user_id);
CREATE INDEX IF NOT EXISTS idx_bio_user_chal ON bio_user_challenges(user_id);
`);
console.log('✓ Biochemistry tables ready');
/* Seed elements */
if (!db.prepare('SELECT 1 FROM bio_elements LIMIT 1').get()) {
const ins = db.prepare('INSERT INTO bio_elements (symbol,name_ru,valency_max,valency_opts,color,radius,mass) VALUES (?,?,?,?,?,?,?)');
[
['H', 'Водород', 1, '[1]', '#D4D4D4', 18, 1.008],
['C', 'Углерод', 4, '[4]', '#555555', 20, 12.011],
['N', 'Азот', 3, '[3,5]', '#4060FF', 20, 14.007],
['O', 'Кислород', 2, '[2]', '#FF2020', 20, 15.999],
['P', 'Фосфор', 5, '[3,5]', '#FF8000', 22, 30.974],
['S', 'Сера', 6, '[2,4,6]', '#C8B400', 22, 32.060],
['Cl', 'Хлор', 1, '[1]', '#00A860', 22, 35.450],
['Na', 'Натрий', 1, '[1]', '#8040C0', 22, 22.990],
['Ca', 'Кальций', 2, '[2]', '#707070', 22, 40.078],
['Mg', 'Магний', 2, '[2]', '#1E8A1E', 22, 24.305],
['Fe', 'Железо', 3, '[2,3]', '#B03010', 22, 55.845],
].forEach(r => ins.run(...r));
console.log('✓ Bio elements seeded');
}
/* Seed molecules */
if (!db.prepare('SELECT 1 FROM bio_molecules LIMIT 1').get()) {
const ins = db.prepare(`INSERT INTO bio_molecules
(formula,name_ru,name_lat,category,difficulty,description,atoms_json,bonds_json,topic_tags)
VALUES (?,?,?,?,?,?,?,?,?)`);
const M = (f,r,l,cat,d,desc,a,b,t) =>
ins.run(f,r,l,cat,d,desc,JSON.stringify(a),JSON.stringify(b),JSON.stringify(t));
// ── Неорганика ──
M('H2O','Вода','Water','inorganic',1,'Основа жизни. Полярная молекула с угловой геометрией (104.5°). Универсальный растворитель.',
[{id:1,s:'O',x:0,y:0},{id:2,s:'H',x:-52,y:40},{id:3,s:'H',x:52,y:40}],
[{f:2,t:1,o:1},{f:3,t:1,o:1}],['вода','полярность','растворитель']);
M('CO2','Углекислый газ','Carbon dioxide','inorganic',1,'Продукт клеточного дыхания. Субстрат фотосинтеза. Линейная молекула.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'O',x:-65,y:0},{id:3,s:'O',x:65,y:0}],
[{f:2,t:1,o:2},{f:3,t:1,o:2}],['фотосинтез','дыхание']);
M('O2','Молекулярный кислород','Oxygen','inorganic',1,'Продукт фотосинтеза. Необходим для аэробного дыхания. Двойная связь O=O.',
[{id:1,s:'O',x:-30,y:0},{id:2,s:'O',x:30,y:0}],
[{f:1,t:2,o:2}],['дыхание','фотосинтез']);
M('N2','Молекулярный азот','Nitrogen','inorganic',1,'78% атмосферы. Тройная связь N≡N — очень прочная. Химически инертен.',
[{id:1,s:'N',x:-30,y:0},{id:2,s:'N',x:30,y:0}],
[{f:1,t:2,o:3}],['атмосфера','азот']);
M('H2','Молекулярный водород','Hydrogen','inorganic',1,'Самый лёгкий газ. Восстановитель. Перспективное топливо будущего.',
[{id:1,s:'H',x:-25,y:0},{id:2,s:'H',x:25,y:0}],
[{f:1,t:2,o:1}],['водород']);
M('NH3','Аммиак','Ammonia','inorganic',1,'Тригонально-пирамидальная молекула. Участвует в синтезе аминокислот. Основание.',
[{id:1,s:'N',x:0,y:-5},{id:2,s:'H',x:-52,y:42},{id:3,s:'H',x:52,y:42},{id:4,s:'H',x:0,y:-60}],
[{f:2,t:1,o:1},{f:3,t:1,o:1},{f:4,t:1,o:1}],['азот','аминокислоты']);
M('HCl','Соляная кислота','Hydrochloric acid','inorganic',1,'Компонент желудочного сока. Сильная одноосновная кислота.',
[{id:1,s:'H',x:-35,y:0},{id:2,s:'Cl',x:35,y:0}],
[{f:1,t:2,o:1}],['кислота','пищеварение']);
M('H2O2','Пероксид водорода','Hydrogen peroxide','inorganic',2,'Сильный окислитель. Используется для дезинфекции ран.',
[{id:1,s:'H',x:-70,y:0},{id:2,s:'O',x:-20,y:0},{id:3,s:'O',x:30,y:0},{id:4,s:'H',x:80,y:0}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:1}],['перекись','дезинфекция']);
M('NaOH','Гидроксид натрия','Sodium hydroxide','inorganic',2,'Щёлочь. Мыльный раствор. Нейтрализует кислоты.',
[{id:1,s:'Na',x:-55,y:0},{id:2,s:'O',x:10,y:0},{id:3,s:'H',x:60,y:0}],
[{f:1,t:2,o:1},{f:2,t:3,o:1}],['основание','щёлочь']);
M('CO','Угарный газ','Carbon monoxide','inorganic',2,'Тройная связь C≡O. Смертельно токсичен. Связывается с гемоглобином.',
[{id:1,s:'C',x:-25,y:0},{id:2,s:'O',x:35,y:0}],
[{f:1,t:2,o:3}],['токсин','гемоглобин']);
// ── Органика ──
M('CH4','Метан','Methane','organic',1,'Простейший алкан. Болотный газ. Тетраэдрическая геометрия.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'H',x:0,y:-58},{id:3,s:'H',x:55,y:30},{id:4,s:'H',x:-55,y:30},{id:5,s:'H',x:0,y:58}],
[{f:2,t:1,o:1},{f:3,t:1,o:1},{f:4,t:1,o:1},{f:5,t:1,o:1}],['алканы','органика']);
M('C2H4','Этилен','Ethylene','organic',2,'Алкен. Двойная связь C=C. Фитогормон — ускоряет созревание плодов.',
[{id:1,s:'C',x:-35,y:0},{id:2,s:'C',x:35,y:0},{id:3,s:'H',x:-72,y:-44},{id:4,s:'H',x:-72,y:44},{id:5,s:'H',x:72,y:-44},{id:6,s:'H',x:72,y:44}],
[{f:1,t:2,o:2},{f:3,t:1,o:1},{f:4,t:1,o:1},{f:5,t:2,o:1},{f:6,t:2,o:1}],['алкены','фитогормоны']);
M('C2H2','Ацетилен','Acetylene','organic',2,'Алкин. Тройная связь C≡C. Линейная молекула. Используется в сварке.',
[{id:1,s:'C',x:-30,y:0},{id:2,s:'C',x:30,y:0},{id:3,s:'H',x:-80,y:0},{id:4,s:'H',x:80,y:0}],
[{f:3,t:1,o:1},{f:1,t:2,o:3},{f:2,t:4,o:1}],['алкины','органика']);
M('C2H5OH','Этанол','Ethanol','organic',2,'Одноатомный спирт. Продукт брожения глюкозы. Растворитель. Нейротоксин.',
[{id:1,s:'C',x:-65,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'O',x:60,y:0},{id:4,s:'H',x:-65,y:-50},{id:5,s:'H',x:-65,y:50},{id:6,s:'H',x:-115,y:0},{id:7,s:'H',x:0,y:-50},{id:8,s:'H',x:0,y:50},{id:9,s:'H',x:95,y:-28}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:4,t:1,o:1},{f:5,t:1,o:1},{f:6,t:1,o:1},{f:7,t:2,o:1},{f:8,t:2,o:1},{f:9,t:3,o:1}],['спирты','брожение','органика']);
M('CH3COOH','Уксусная кислота','Acetic acid','organic',2,'Карбоновая кислота. Продукт окисления этанола. Компонент уксуса.',
[{id:1,s:'C',x:-55,y:0},{id:2,s:'C',x:10,y:0},{id:3,s:'O',x:65,y:-38},{id:4,s:'O',x:65,y:38},{id:5,s:'H',x:-55,y:-50},{id:6,s:'H',x:-55,y:50},{id:7,s:'H',x:-105,y:0},{id:8,s:'H',x:90,y:52}],
[{f:1,t:2,o:1},{f:2,t:3,o:2},{f:2,t:4,o:1},{f:5,t:1,o:1},{f:6,t:1,o:1},{f:7,t:1,o:1},{f:8,t:4,o:1}],['карбоновые кислоты','органика']);
console.log('✓ Bio molecules seeded');
}
/* Additional molecules (safe for existing DBs — inserts only if name absent) */
{
const addMol = db.prepare(`INSERT INTO bio_molecules
(formula,name_ru,name_lat,category,difficulty,description,atoms_json,bonds_json,topic_tags)
SELECT ?,?,?,?,?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM bio_molecules WHERE name_ru=?)`);
const A = (f,r,l,cat,d,desc,a,b,t) =>
addMol.run(f,r,l,cat,d,desc,JSON.stringify(a),JSON.stringify(b),JSON.stringify(t),r);
A('C2H6','Этан','Ethane','organic',1,'Второй алкан. Одинарная C-C связь. Компонент природного газа.',
[{id:1,s:'C',x:-40,y:0},{id:2,s:'C',x:40,y:0},{id:3,s:'H',x:-40,y:-55},{id:4,s:'H',x:-40,y:55},{id:5,s:'H',x:-95,y:0},{id:6,s:'H',x:40,y:-55},{id:7,s:'H',x:40,y:55},{id:8,s:'H',x:95,y:0}],
[{f:1,t:2,o:1},{f:3,t:1,o:1},{f:4,t:1,o:1},{f:5,t:1,o:1},{f:6,t:2,o:1},{f:7,t:2,o:1},{f:8,t:2,o:1}],['алканы','органика']);
A('C3H8','Пропан','Propane','organic',2,'Третий алкан. Три атома углерода. Используется как топливо.',
[{id:1,s:'C',x:-80,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:80,y:0},{id:4,s:'H',x:-80,y:-52},{id:5,s:'H',x:-80,y:52},{id:6,s:'H',x:-130,y:0},{id:7,s:'H',x:0,y:-52},{id:8,s:'H',x:0,y:52},{id:9,s:'H',x:80,y:-52},{id:10,s:'H',x:80,y:52},{id:11,s:'H',x:130,y:0}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:4,t:1,o:1},{f:5,t:1,o:1},{f:6,t:1,o:1},{f:7,t:2,o:1},{f:8,t:2,o:1},{f:9,t:3,o:1},{f:10,t:3,o:1},{f:11,t:3,o:1}],['алканы','органика']);
A('HCHO','Формальдегид','Formaldehyde','organic',2,'Простейший альдегид. Двойная C=O. Консервант (формалин). Токсичен.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'H',x:-52,y:-38},{id:3,s:'H',x:-52,y:38},{id:4,s:'O',x:60,y:0}],
[{f:2,t:1,o:1},{f:3,t:1,o:1},{f:1,t:4,o:2}],['альдегиды','органика']);
A('C3H6O','Ацетон','Acetone','organic',2,'Простейший кетон. C=O в центре цепи. Растворитель.',
[{id:1,s:'C',x:-65,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:65,y:0},{id:4,s:'O',x:0,y:-55},{id:5,s:'H',x:-65,y:-50},{id:6,s:'H',x:-65,y:50},{id:7,s:'H',x:-115,y:0},{id:8,s:'H',x:65,y:-50},{id:9,s:'H',x:65,y:50},{id:10,s:'H',x:115,y:0}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:2,t:4,o:2},{f:5,t:1,o:1},{f:6,t:1,o:1},{f:7,t:1,o:1},{f:8,t:3,o:1},{f:9,t:3,o:1},{f:10,t:3,o:1}],['кетоны','органика']);
A('HCOOH','Муравьиная кислота','Formic acid','organic',2,'Простейшая карбоновая кислота. В яде муравьёв.',
[{id:1,s:'H',x:-70,y:0},{id:2,s:'C',x:-10,y:0},{id:3,s:'O',x:45,y:-40},{id:4,s:'O',x:45,y:40},{id:5,s:'H',x:82,y:54}],
[{f:1,t:2,o:1},{f:2,t:3,o:2},{f:2,t:4,o:1},{f:4,t:5,o:1}],['карбоновые кислоты','органика']);
A('C2H5NO2','Глицин','Glycine','aminoacid',3,'Простейшая аминокислота. NH₂-CH₂-COOH. Нейромедиатор.',
[{id:1,s:'N',x:-90,y:0},{id:2,s:'C',x:-25,y:0},{id:3,s:'C',x:40,y:0},{id:4,s:'O',x:95,y:-35},{id:5,s:'O',x:95,y:35},{id:6,s:'H',x:-90,y:-48},{id:7,s:'H',x:-90,y:48},{id:8,s:'H',x:-25,y:-50},{id:9,s:'H',x:-25,y:50},{id:10,s:'H',x:118,y:48}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:6,t:1,o:1},{f:7,t:1,o:1},{f:8,t:2,o:1},{f:9,t:2,o:1},{f:10,t:5,o:1}],['аминокислоты','белки']);
A('NaCl','Хлорид натрия','Sodium chloride','inorganic',1,'Поваренная соль. Важнейший электролит крови.',
[{id:1,s:'Na',x:-35,y:0},{id:2,s:'Cl',x:35,y:0}],
[{f:1,t:2,o:1}],['соль','электролиты','кровь']);
A('H2SO4','Серная кислота','Sulfuric acid','inorganic',3,'Сильная двухосновная кислота. Дегидратирующий агент.',
[{id:1,s:'S',x:0,y:0},{id:2,s:'O',x:0,y:-58},{id:3,s:'O',x:0,y:58},{id:4,s:'O',x:-55,y:0},{id:5,s:'O',x:55,y:0},{id:6,s:'H',x:-92,y:0},{id:7,s:'H',x:92,y:0}],
[{f:1,t:2,o:2},{f:1,t:3,o:2},{f:1,t:4,o:1},{f:1,t:5,o:1},{f:4,t:6,o:1},{f:5,t:7,o:1}],['кислота','промышленность']);
A('HNO3','Азотная кислота','Nitric acid','inorganic',3,'Сильная кислота-окислитель. Синтез удобрений.',
[{id:1,s:'H',x:-90,y:30},{id:2,s:'O',x:-45,y:30},{id:3,s:'N',x:5,y:0},{id:4,s:'O',x:55,y:-35},{id:5,s:'O',x:55,y:35}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1}],['кислота','азот']);
console.log('✓ Additional bio molecules checked/added');
}
/* ── Phase 6: 30+ more molecules ─────────────────────────────────────────── */
{
const P6 = db.prepare(`INSERT INTO bio_molecules
(formula,name_ru,name_lat,category,difficulty,description,atoms_json,bonds_json,topic_tags,is_library)
SELECT ?,?,?,?,?,?,?,?,?,1 WHERE NOT EXISTS (SELECT 1 FROM bio_molecules WHERE name_ru=?)`);
const M = (f,r,l,cat,d,desc,a,b,t) =>
P6.run(f,r,l,cat,d,desc,JSON.stringify(a),JSON.stringify(b),JSON.stringify(t),r);
// ── Неорганика ──
M('H2S','Сероводород','Hydrogen sulfide','inorganic',1,
'Ядовитый газ с запахом тухлых яиц. Участвует в круговороте серы. Слабая кислота.',
[{id:1,s:'S',x:0,y:0},{id:2,s:'H',x:-48,y:36},{id:3,s:'H',x:48,y:36}],
[{f:2,t:1,o:1},{f:3,t:1,o:1}],['сера','кислота']);
M('SO2','Диоксид серы','Sulfur dioxide','inorganic',2,
'Бесцветный газ с резким запахом. Образуется при сжигании серосодержащего топлива. Кислотный оксид.',
[{id:1,s:'S',x:0,y:0},{id:2,s:'O',x:-65,y:-15},{id:3,s:'O',x:65,y:-15}],
[{f:2,t:1,o:2},{f:3,t:1,o:2}],['сера','кислотный оксид','атмосфера']);
M('SO3','Триоксид серы','Sulfur trioxide','inorganic',2,
'Реагирует с водой, образуя серную кислоту H₂SO₄. Тригональная плоская молекула.',
[{id:1,s:'S',x:0,y:0},{id:2,s:'O',x:-65,y:38},{id:3,s:'O',x:65,y:38},{id:4,s:'O',x:0,y:-65}],
[{f:2,t:1,o:2},{f:3,t:1,o:2},{f:4,t:1,o:2}],['сера','кислота']);
M('NO','Оксид азота (II)','Nitric oxide','inorganic',2,
'Нейромедиатор. Регулирует сосудистый тонус. Важная молекула-сигнал в организме.',
[{id:1,s:'N',x:-30,y:0},{id:2,s:'O',x:30,y:0}],
[{f:1,t:2,o:2}],['азот','нейромедиатор','сигнал']);
M('NO2','Диоксид азота','Nitrogen dioxide','inorganic',2,
'Бурый ядовитый газ. Компонент смога. Участвует в образовании кислотных дождей.',
[{id:1,s:'N',x:0,y:0},{id:2,s:'O',x:-60,y:-20},{id:3,s:'O',x:60,y:-20}],
[{f:2,t:1,o:2},{f:3,t:1,o:1}],['азот','смог','атмосфера']);
M('MgCl2','Хлорид магния','Magnesium chloride','inorganic',1,
'Ионное соединение. Содержится в морской воде. Применяется как противогололёдный реагент.',
[{id:1,s:'Cl',x:-80,y:0},{id:2,s:'Mg',x:0,y:0},{id:3,s:'Cl',x:80,y:0}],
[{f:1,t:2,o:1},{f:3,t:2,o:1}],['магний','ионная связь','соль']);
M('CaO','Оксид кальция','Calcium oxide','inorganic',1,
'Негашёная известь. Щелочной оксид. Реагирует с водой с выделением большого количества тепла.',
[{id:1,s:'Ca',x:-35,y:0},{id:2,s:'O',x:35,y:0}],
[{f:1,t:2,o:2}],['кальций','оксид','основной оксид']);
M('FeCl3','Хлорид железа (III)','Iron(III) chloride','inorganic',2,
'Качественный реагент на фенол. Катализатор хлорирования ароматических соединений.',
[{id:1,s:'Fe',x:0,y:0},{id:2,s:'Cl',x:-70,y:45},{id:3,s:'Cl',x:70,y:45},{id:4,s:'Cl',x:0,y:-75}],
[{f:1,t:2,o:1},{f:1,t:3,o:1},{f:1,t:4,o:1}],['железо','катализатор','галоген']);
M('H3PO4','Фосфорная кислота','Phosphoric acid','inorganic',3,
'Трёхосновная кислота. Применяется в производстве удобрений и пищевой промышленности (E338).',
[{id:1,s:'P',x:0,y:0},{id:2,s:'O',x:0,y:-65},{id:3,s:'O',x:-60,y:45},{id:4,s:'O',x:60,y:45},{id:5,s:'O',x:75,y:-45},
{id:6,s:'H',x:-95,y:70},{id:7,s:'H',x:95,y:70},{id:8,s:'H',x:110,y:-65}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:1,t:5,o:1},
{f:6,t:3,o:1},{f:7,t:4,o:1},{f:8,t:5,o:1}],['фосфор','кислота','удобрения']);
// ── Органика ──
M('CH3OH','Метанол','Methanol','organic',2,
'Простейший спирт. Ядовит! Вызывает слепоту и смерть. Используется как топливо и растворитель.',
[{id:1,s:'C',x:-25,y:0},{id:2,s:'O',x:40,y:0},{id:3,s:'H',x:80,y:0},
{id:4,s:'H',x:-58,y:-35},{id:5,s:'H',x:-58,y:35},{id:6,s:'H',x:-58,y:0}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:4,t:1,o:1},{f:5,t:1,o:1},{f:6,t:1,o:1}],
['спирт','топливо','токсин']);
M('C3H7OH','Пропанол','Propanol','organic',2,
'Спирт с 3 углеродами. Растворитель. Компонент антисептических средств. CH₃CH₂CH₂OH.',
[{id:1,s:'C',x:-65,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:65,y:0},{id:4,s:'O',x:110,y:0},{id:5,s:'H',x:150,y:0},
{id:6,s:'H',x:-100,y:-35},{id:7,s:'H',x:-100,y:35},{id:8,s:'H',x:-100,y:0},
{id:9,s:'H',x:0,y:-45},{id:10,s:'H',x:0,y:45},
{id:11,s:'H',x:65,y:-45},{id:12,s:'H',x:65,y:45}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:1},{f:4,t:5,o:1},
{f:6,t:1,o:1},{f:7,t:1,o:1},{f:8,t:1,o:1},
{f:9,t:2,o:1},{f:10,t:2,o:1},{f:11,t:3,o:1},{f:12,t:3,o:1}],
['спирт','антисептик','растворитель']);
M('C2H4O','Ацетальдегид','Acetaldehyde','organic',2,
'Простейший альдегид. Промежуточный продукт окисления этанола. Образуется при брожении. CH₃CHO.',
[{id:1,s:'C',x:-35,y:0},{id:2,s:'C',x:35,y:0},{id:3,s:'O',x:80,y:-35},{id:4,s:'H',x:80,y:35},
{id:5,s:'H',x:-70,y:-38},{id:6,s:'H',x:-70,y:38},{id:7,s:'H',x:-70,y:0}],
[{f:1,t:2,o:1},{f:2,t:3,o:2},{f:2,t:4,o:1},{f:5,t:1,o:1},{f:6,t:1,o:1},{f:7,t:1,o:1}],
['альдегид','брожение','метаболизм']);
M('C6H6','Бензол','Benzene','organic',3,
'Ароматический углеводород с кольцом из 6 углеродов. Канцероген. Основа ароматической химии.',
[{id:1,s:'C',x:50,y:0},{id:2,s:'C',x:25,y:43},{id:3,s:'C',x:-25,y:43},
{id:4,s:'C',x:-50,y:0},{id:5,s:'C',x:-25,y:-43},{id:6,s:'C',x:25,y:-43},
{id:7,s:'H',x:90,y:0},{id:8,s:'H',x:45,y:78},{id:9,s:'H',x:-45,y:78},
{id:10,s:'H',x:-90,y:0},{id:11,s:'H',x:-45,y:-78},{id:12,s:'H',x:45,y:-78}],
[{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1},
{f:1,t:7,o:1},{f:2,t:8,o:1},{f:3,t:9,o:1},{f:4,t:10,o:1},{f:5,t:11,o:1},{f:6,t:12,o:1}],
['ароматика','кольцо','канцероген']);
M('C3H6','Пропилен','Propylene','organic',2,
'Простейший алкен с двойной связью. CH₂=CH-CH₃. Сырьё для производства полипропилена.',
[{id:1,s:'C',x:-65,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:65,y:0},
{id:4,s:'H',x:-100,y:-35},{id:5,s:'H',x:-100,y:35},{id:6,s:'H',x:0,y:-50},
{id:7,s:'H',x:100,y:-35},{id:8,s:'H',x:100,y:5},{id:9,s:'H',x:100,y:45}],
[{f:1,t:2,o:2},{f:2,t:3,o:1},
{f:4,t:1,o:1},{f:5,t:1,o:1},{f:6,t:2,o:1},
{f:7,t:3,o:1},{f:8,t:3,o:1},{f:9,t:3,o:1}],
['алкен','полимер','пластик']);
M('C4H10','Бутан','Butane','organic',2,
'Четырёхуглеродный алкан. Сжиженный газ в зажигалках и баллонах. CH₃CH₂CH₂CH₃.',
[{id:1,s:'C',x:-90,y:0},{id:2,s:'C',x:-30,y:0},{id:3,s:'C',x:30,y:0},{id:4,s:'C',x:90,y:0},
{id:5,s:'H',x:-125,y:-32},{id:6,s:'H',x:-125,y:32},{id:7,s:'H',x:-90,y:-45},
{id:8,s:'H',x:-30,y:-45},{id:9,s:'H',x:-30,y:45},
{id:10,s:'H',x:30,y:-45},{id:11,s:'H',x:30,y:45},
{id:12,s:'H',x:125,y:-32},{id:13,s:'H',x:125,y:32},{id:14,s:'H',x:90,y:-45}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:1},
{f:5,t:1,o:1},{f:6,t:1,o:1},{f:7,t:1,o:1},
{f:8,t:2,o:1},{f:9,t:2,o:1},{f:10,t:3,o:1},{f:11,t:3,o:1},
{f:12,t:4,o:1},{f:13,t:4,o:1},{f:14,t:4,o:1}],
['алкан','топливо','газ']);
M('CHCl3','Хлороформ','Chloroform','organic',2,
'Исторически использовался как анестетик. Гепатотоксичен. Растворитель. CHCl₃.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'H',x:0,y:-65},
{id:3,s:'Cl',x:-60,y:45},{id:4,s:'Cl',x:60,y:45},{id:5,s:'Cl',x:0,y:65}],
[{f:1,t:2,o:1},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:1,t:5,o:1}],
['галоген','растворитель','анестетик']);
M('CCl4','Тетрахлорметан','Carbon tetrachloride','organic',2,
'Тетраэдрическая молекула. Применялся как огнетушитель. Токсичен. Разрушает озоновый слой.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'Cl',x:0,y:-75},{id:3,s:'Cl',x:75,y:0},{id:4,s:'Cl',x:0,y:75},{id:5,s:'Cl',x:-75,y:0}],
[{f:1,t:2,o:1},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:1,t:5,o:1}],
['галоген','симметрия','растворитель']);
M('CH4N2O','Мочевина','Urea','organic',2,
'Конечный продукт азотистого обмена. Экскретируется почками. Используется как удобрение. O=C(NH₂)₂.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'O',x:0,y:-65},{id:3,s:'N',x:-60,y:40},{id:4,s:'N',x:60,y:40},
{id:5,s:'H',x:-95,y:65},{id:6,s:'H',x:-30,y:75},{id:7,s:'H',x:30,y:75},{id:8,s:'H',x:95,y:65}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},
{f:5,t:3,o:1},{f:6,t:3,o:1},{f:7,t:4,o:1},{f:8,t:4,o:1}],
['белок','обмен','удобрение','почки']);
M('C2H4O2','Метилформиат','Methyl formate','organic',2,
'Простейший сложный эфир. Применяется как фумигант и растворитель. HCOOCH₃.',
[{id:1,s:'C',x:-35,y:0},{id:2,s:'O',x:25,y:0},{id:3,s:'C',x:90,y:0},{id:4,s:'O',x:-35,y:-65},
{id:5,s:'H',x:-70,y:0},{id:6,s:'H',x:125,y:-35},{id:7,s:'H',x:125,y:35},{id:8,s:'H',x:125,y:0}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:1,t:4,o:2},{f:5,t:1,o:1},
{f:6,t:3,o:1},{f:7,t:3,o:1},{f:8,t:3,o:1}],
['эфир','растворитель']);
// ── Биомолекулы ──
M('C3H7NO2','Аланин','Alanine','biomolecule',3,
'Простейшая хиральная аминокислота. Входит в состав белков. Заменимая АК. CH₃-CH(NH₂)-COOH.',
[{id:1,s:'C',x:-65,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:65,y:0},
{id:4,s:'N',x:0,y:-65},{id:5,s:'O',x:100,y:-35},{id:6,s:'O',x:100,y:35},
{id:7,s:'H',x:-100,y:-35},{id:8,s:'H',x:-100,y:35},{id:9,s:'H',x:-100,y:0},
{id:10,s:'H',x:0,y:35},{id:11,s:'H',x:-30,y:-90},{id:12,s:'H',x:30,y:-90},{id:13,s:'H',x:135,y:35}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:2,t:4,o:1},{f:3,t:5,o:2},{f:3,t:6,o:1},
{f:7,t:1,o:1},{f:8,t:1,o:1},{f:9,t:1,o:1},
{f:10,t:2,o:1},{f:11,t:4,o:1},{f:12,t:4,o:1},{f:13,t:6,o:1}],
['аминокислота','белок','хиральность']);
M('C3H6O3','Молочная кислота','Lactic acid','biomolecule',2,
'Образуется при анаэробном гликолизе. Причина усталости мышц. CH₃-CH(OH)-COOH.',
[{id:1,s:'C',x:-65,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:65,y:0},
{id:4,s:'O',x:0,y:-65},{id:5,s:'O',x:100,y:-35},{id:6,s:'O',x:100,y:35},
{id:7,s:'H',x:-100,y:-35},{id:8,s:'H',x:-100,y:35},{id:9,s:'H',x:-100,y:0},
{id:10,s:'H',x:0,y:35},{id:11,s:'H',x:-30,y:-90},{id:12,s:'H',x:135,y:35}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:2,t:4,o:1},{f:3,t:5,o:2},{f:3,t:6,o:1},
{f:7,t:1,o:1},{f:8,t:1,o:1},{f:9,t:1,o:1},
{f:10,t:2,o:1},{f:11,t:4,o:1},{f:12,t:6,o:1}],
['гликолиз','мышцы','анаэробный']);
M('C3H4O3','Пировиноградная кислота','Pyruvic acid','biomolecule',3,
'Ключевой метаболит. Конечный продукт гликолиза. Точка ветвления: ЦТК, брожение, синтез АК. CH₃-CO-COOH.',
[{id:1,s:'C',x:-65,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:65,y:0},
{id:4,s:'O',x:0,y:-65},{id:5,s:'O',x:100,y:-35},{id:6,s:'O',x:100,y:35},
{id:7,s:'H',x:-100,y:-35},{id:8,s:'H',x:-100,y:35},{id:9,s:'H',x:-100,y:0},{id:10,s:'H',x:135,y:35}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:2,t:4,o:2},{f:3,t:5,o:2},{f:3,t:6,o:1},
{f:7,t:1,o:1},{f:8,t:1,o:1},{f:9,t:1,o:1},{f:10,t:6,o:1}],
['гликолиз','Кребс','метаболизм','ЦТК']);
M('C6H12O6','Глюкоза','Glucose','biomolecule',3,
'Основной источник энергии клетки. Субстрат гликолиза. Моносахарид. Образуется при фотосинтезе.',
[],[],['углевод','гликолиз','энергия','фотосинтез']);
M('C12H22O11','Сахароза','Sucrose','biomolecule',3,
'Дисахарид: глюкоза + фруктоза. Обычный столовый сахар. Гидролизуется на моносахариды.',
[],[],['углевод','дисахарид','сахар']);
M('C5H5N5','Аденин','Adenine','biomolecule',3,
'Пуриновое основание. Входит в состав ДНК, РНК и АТФ. Комплементарен тимину в ДНК.',
[],[],['нуклеотид','ДНК','АТФ','пурин']);
M('C5H6N2O2','Тимин','Thymine','biomolecule',3,
'Пиримидиновое основание. Только в ДНК (в РНК заменяется урацилом). Пара аденину.',
[],[],['нуклеотид','ДНК','пиримидин']);
M('C4H5N3O','Цитозин','Cytosine','biomolecule',3,
'Пиримидиновое основание. Входит в ДНК и РНК. Пара гуанину (три водородные связи).',
[],[],['нуклеотид','ДНК','РНК','пиримидин']);
M('C5H5N5O','Гуанин','Guanine','biomolecule',3,
'Пуриновое основание. Входит в ДНК и РНК. Пара цитозину. Наибольшее содержание в ДНК термофилов.',
[],[],['нуклеотид','ДНК','РНК','пурин']);
M('C4H4N2O2','Урацил','Uracil','biomolecule',3,
'Пиримидиновое основание. Только в РНК (вместо тимина). Пара аденину.',
[],[],['нуклеотид','РНК','пиримидин']);
M('C5H10O5','Рибоза','Ribose','biomolecule',3,
'Пентоза. Входит в состав РНК, АТФ, НАД⁺. Структурный компонент нуклеотидов.',
[],[],['углевод','РНК','нуклеотид','пентоза']);
M('C5H10O4','Дезоксирибоза','Deoxyribose','biomolecule',3,
'Пятиуглеродный сахар. Структурный компонент ДНК. Отличается от рибозы отсутствием одного OH.',
[],[],['углевод','ДНК','нуклеотид']);
M('C16H32O2','Пальмитиновая кислота','Palmitic acid','biomolecule',2,
'Насыщенная жирная кислота С16. Входит в состав клеточных мембран и пальмового масла.',
[],[],['жирная кислота','липид','мембрана','насыщенная']);
M('C18H34O2','Олеиновая кислота','Oleic acid','biomolecule',2,
'Мononенасыщенная ω-9 жирная кислота. Основной компонент оливкового масла. Двойная связь Δ9.',
[],[],['жирная кислота','ненасыщенная','оливковое масло','омега']);
M('C3H5NO2','Серин','Serine','biomolecule',3,
'Полярная аминокислота с гидроксильной группой. Входит в активный центр многих ферментов.',
[],[],['аминокислота','белок','фермент']);
M('C3H7NO3','Треонин','Threonine','biomolecule',3,
'Незаменимая полярная аминокислота. Содержит гидроксильную группу. Участвует в сигнальных каскадах.',
[],[],['аминокислота','незаменимая','белок']);
M('C10H17N3O6S','Глутатион','Glutathione','biomolecule',3,
'Трипептид (γ-Glu-Cys-Gly). Важнейший клеточный антиоксидант. Обезвреживает перекисные радикалы.',
[],[],['антиоксидант','пептид','цистеин']);
M('C21H30O5','Кортизол','Cortisol','biomolecule',3,
'Стероидный гормон стресса. Вырабатывается корой надпочечников. Регулирует обмен веществ.',
[],[],['гормон','стероид','стресс','надпочечники']);
M('C18H21NO3','Морфин','Morphine','biomolecule',3,
'Алкалоид мака. Сильное обезболивающее. Взаимодействует с опиоидными рецепторами.',
[],[],['алкалоид','нейромедиатор','рецептор']);
console.log('✓ Phase 6: Additional molecules seeded');
}
/* Seed challenges */
if (!db.prepare('SELECT 1 FROM bio_challenges LIMIT 1').get()) {
const ins = db.prepare('INSERT INTO bio_challenges (title,description,type,target_formula,hint,xp_reward,difficulty,topic_tag,order_n) VALUES (?,?,?,?,?,?,?,?,?)');
[
['Построй воду', 'Соедини 2 атома водорода с кислородом', 'build','H2O','O — в центре, два H по бокам',30,1,'вода',1],
['Углекислый газ', 'Построй молекулу CO₂ — двойные связи!', 'build','CO2','C в центре, два O с двойными связями',40,1,'фотосинтез',2],
['Кислород O₂', 'Две O соединены двойной связью', 'build','O2','Двойная связь: перетащи от атома до второго О',30,1,'дыхание',3],
['Азот N₂', 'Тройная связь между двумя атомами N', 'build','N2','Тройная связь: кликни по линии 3 раза',50,2,'атмосфера',4],
['Аммиак NH₃', 'Три водорода связаны с азотом', 'build','NH3','N в центре, три H вокруг',50,2,'азот',5],
['Метан CH₄', 'Четыре водорода вокруг углерода', 'build','CH4','C имеет 4 свободные связи — к каждой прикрепи H',60,2,'алканы',6],
['Этилен C₂H₄', 'Двойная связь C=C — признак алкенов', 'build','C2H4','Сначала соедини два C двойной связью, затем добавь H',80,3,'алкены',7],
['Ацетилен C₂H₂', 'Тройная связь C≡C — самая прочная', 'build','C2H2','H-C≡C-H: два H по краям, тройная связь в центре',100,3,'алкины',8],
].forEach(r => ins.run(...r));
console.log('✓ Bio challenges seeded');
}
/* Seed reactions */
if (!db.prepare('SELECT 1 FROM bio_reactions LIMIT 1').get()) {
const ins = db.prepare('INSERT INTO bio_reactions (equation,name_ru,type,description,reactant_ids,product_ids,conditions,energy_kj,topic_tags) VALUES (?,?,?,?,?,?,?,?,?)');
const empty = '[]';
[
['2H₂ + O₂ → 2H₂O','Синтез воды','synthesis','Экзотермическая реакция. Основа водородной энергетики. ΔH = –483 кДж/моль.',empty,empty,'поджигание',-483.6,'["водород","кислород","вода"]'],
['CH₄ + 2O₂ → CO₂ + 2H₂O','Горение метана','combustion','Горение природного газа. Выделяет тепло. ΔH = –890 кДж/моль.',empty,empty,'t°',-890.0,'["метан","горение","углерод"]'],
['N₂ + 3H₂ ⇌ 2NH₃','Синтез аммиака (Хабер)','synthesis','Промышленный синтез. Высокое давление и температура. Обратимая реакция.',empty,empty,'450°C, Fe-кат, 200 атм',-92.4,'["азот","аммиак","промышленность"]'],
['6CO₂ + 6H₂O → C₆H₁₂O₆ + 6O₂','Фотосинтез','synthesis','Суммарное уравнение фотосинтеза. Световая + тёмная фазы.',empty,empty,'hν (свет)',2808,'["фотосинтез","глюкоза","хлоропласт"]'],
['C₆H₁₂O₆ + 6O₂ → 6CO₂ + 6H₂O','Клеточное дыхание','decomposition','Полное окисление глюкозы. Выделяется 38 АТФ. ΔH = –2808 кДж/моль.',empty,empty,'ферменты',-2808,'["дыхание","АТФ","митохондрия"]'],
].forEach(r => ins.run(...r));
console.log('✓ Bio reactions seeded');
}
/* Fix reaction molecule IDs (seeded with empty arrays) */
{
const firstRxn = db.prepare("SELECT reactant_ids FROM bio_reactions WHERE name_ru='Синтез воды'").get();
if (firstRxn && firstRxn.reactant_ids === '[]') {
const mid = f => db.prepare('SELECT id FROM bio_molecules WHERE formula=? LIMIT 1').get(f)?.id;
const upd = db.prepare('UPDATE bio_reactions SET reactant_ids=?, product_ids=? WHERE name_ru=?');
const rxnIds = [
{ name: 'Синтез воды', r: [mid('H2'), mid('O2')], p: [mid('H2O')] },
{ name: 'Горение метана', r: [mid('CH4'), mid('O2')], p: [mid('CO2'), mid('H2O')] },
{ name: 'Синтез аммиака (Хабер)', r: [mid('N2'), mid('H2')], p: [mid('NH3')] },
{ name: 'Фотосинтез', r: [mid('CO2'), mid('H2O')], p: [mid('O2')] },
{ name: 'Клеточное дыхание', r: [mid('O2')], p: [mid('CO2'), mid('H2O')] },
];
for (const u of rxnIds) {
const rIds = u.r.filter(Boolean), pIds = u.p.filter(Boolean);
if (rIds.length || pIds.length)
upd.run(JSON.stringify(rIds), JSON.stringify(pIds), u.name);
}
console.log('✓ Bio reaction molecule IDs fixed');
}
}
/* Add data_json column to bio_challenges (idempotent) */
{
const chalCols = db.prepare('PRAGMA table_info(bio_challenges)').all().map(c => c.name);
if (!chalCols.includes('data_json')) {
db.exec('ALTER TABLE bio_challenges ADD COLUMN data_json TEXT DEFAULT NULL');
console.log('✓ bio_challenges.data_json column added');
}
}
/* Seed identify + formula challenges */
{
const insChal = db.prepare(`
INSERT INTO bio_challenges (title,description,type,target_formula,hint,xp_reward,difficulty,topic_tag,order_n,data_json)
SELECT ?,?,?,?,?,?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM bio_challenges WHERE title=?)`);
const mid = f => db.prepare('SELECT id FROM bio_molecules WHERE formula=? LIMIT 1').get(f)?.id;
const mname = f => db.prepare('SELECT name_ru FROM bio_molecules WHERE formula=? LIMIT 1').get(f)?.name_ru;
// identify: show molecule structure → pick correct name
[
{ title:'Узнай молекулу: вода', f:'H2O', choices:['Вода','Аммиак','Метан','Углекислый газ'], xp:30, d:1, t:'вода', n:9 },
{ title:'Узнай молекулу: CO₂', f:'CO2', choices:['Углекислый газ','Угарный газ','Кислород','Аммиак'], xp:30, d:1, t:'фотосинтез', n:10 },
{ title:'Узнай молекулу: аммиак', f:'NH3', choices:['Аммиак','Азот','Вода','Метан'], xp:40, d:2, t:'азот', n:11 },
{ title:'Узнай молекулу: метан', f:'CH4', choices:['Метан','Этан','Пропан','Этилен'], xp:40, d:2, t:'алканы', n:12 },
{ title:'Узнай молекулу: этилен', f:'C2H4', choices:['Этилен','Этан','Ацетилен','Метан'], xp:60, d:3, t:'алкены', n:13 },
].forEach(c => {
const mol_id = mid(c.f);
insChal.run(c.title, 'Определи молекулу по её структурной формуле', 'identify', c.f, null, c.xp, c.d, c.t, c.n,
JSON.stringify({ mol_id, choices: c.choices }), c.title);
});
// formula: shown a molecule name → pick correct formula
[
{ title:'Формула воды', name:'воды', f:'H2O', choices:['H2O','H2O2','HO','H3O'], xp:25, d:1, t:'вода', n:14 },
{ title:'Формула аммиака', name:'аммиака', f:'NH3', choices:['NH3','N2H4','NO2','HNO3'], xp:35, d:2, t:'азот', n:15 },
{ title:'Формула метана', name:'метана (простейший алкан)', f:'CH4',choices:['CH4','C2H6','C2H4','CH3'],xp:35, d:2, t:'алканы', n:16 },
{ title:'Формула кислорода', name:'молекулярного кислорода', f:'O2', choices:['O2','O3','CO','CO2'], xp:25, d:1, t:'дыхание',n:17 },
].forEach(c => {
const mol_name = mname(c.f) || c.name;
insChal.run(c.title, `Выбери правильную химическую формулу: ${mol_name}`, 'formula', c.f, null, c.xp, c.d, c.t, c.n,
JSON.stringify({ choices: c.choices, mol_name }), c.title);
});
console.log('✓ Bio identify/formula challenges seeded');
}
/* ── ПЛАН ФАЗА 1: Расширение контента ─────────────────────────────────── */
{
const am = db.prepare(`INSERT INTO bio_molecules (formula,name_ru,name_lat,category,difficulty,description,atoms_json,bonds_json,topic_tags,is_library)
SELECT ?,?,?,?,?,?,?,?,?,1 WHERE NOT EXISTS (SELECT 1 FROM bio_molecules WHERE name_ru=?)`);
const M = (f,r,l,cat,d,desc,a,b,t) => am.run(f,r,l,cat,d,desc,JSON.stringify(a),JSON.stringify(b),JSON.stringify(t),r);
// === INORGANIC MOLECULES ===
M('KOH','Гидроксид калия','Potassium hydroxide','inorganic',1,
'Сильное основание. Используется в промышленности и лабораторной практике.',
[{id:1,s:'K',x:-80,y:0},{id:2,s:'O',x:0,y:0},{id:3,s:'H',x:80,y:0}],
[{f:1,t:2,o:1},{f:2,t:3,o:1}],['кислоты-основания']);
M('Ca(OH)2','Гидроксид кальция','Calcium hydroxide','inorganic',1,
'Гашёная известь. Применяется в строительстве и сельском хозяйстве.',
[{id:1,s:'Ca',x:0,y:0},{id:2,s:'O',x:-80,y:60},{id:3,s:'H',x:-80,y:140},{id:4,s:'O',x:80,y:60},{id:5,s:'H',x:80,y:140}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:1,t:4,o:1},{f:4,t:5,o:1}],['кислоты-основания']);
M('Fe2O3','Оксид железа(III)','Iron(III) oxide','inorganic',2,
'Ржавчина. Красно-бурый порошок, встречается как минерал гематит.',
[{id:1,s:'Fe',x:-80,y:0},{id:2,s:'Fe',x:80,y:0},{id:3,s:'O',x:0,y:-80},{id:4,s:'O',x:-120,y:80},{id:5,s:'O',x:120,y:80}],
[{f:1,t:3,o:1},{f:2,t:3,o:1},{f:1,t:4,o:1},{f:2,t:5,o:1}],['металлы','оксиды']);
M('Al2O3','Оксид алюминия','Aluminium oxide','inorganic',2,
'Корунд, глинозём. Тугоплавкое вещество, основа алюминиевой промышленности.',
[{id:1,s:'Al',x:-80,y:0},{id:2,s:'Al',x:80,y:0},{id:3,s:'O',x:0,y:-80},{id:4,s:'O',x:-120,y:80},{id:5,s:'O',x:120,y:80}],
[{f:1,t:3,o:1},{f:2,t:3,o:1},{f:1,t:4,o:1},{f:2,t:5,o:1}],['металлы','оксиды']);
M('Na2CO3','Карбонат натрия','Sodium carbonate','inorganic',2,
'Сода кальцинированная. Применяется в стекольной и химической промышленности.',
[{id:1,s:'Na',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'O',x:0,y:-85},{id:4,s:'O',x:-75,y:65},{id:5,s:'O',x:75,y:65},{id:6,s:'Na',x:120,y:0}],
[{f:2,t:3,o:2},{f:2,t:4,o:1},{f:2,t:5,o:1},{f:4,t:1,o:1},{f:5,t:6,o:1}],['соли']);
M('NaHCO3','Гидрокарбонат натрия','Sodium bicarbonate','inorganic',2,
'Питьевая сода. Разлагается при нагревании с выделением CO₂.',
[{id:1,s:'Na',x:-140,y:0},{id:2,s:'O',x:-50,y:0},{id:3,s:'H',x:-50,y:-80},{id:4,s:'C',x:40,y:0},{id:5,s:'O',x:40,y:-85},{id:6,s:'O',x:120,y:50}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:2,t:4,o:1},{f:4,t:5,o:2},{f:4,t:6,o:1}],['соли']);
M('KMnO4','Перманганат калия','Potassium permanganate','inorganic',2,
'Марганцовка. Сильный окислитель. Применяется как антисептик.',
[{id:1,s:'K',x:-130,y:0},{id:2,s:'Mn',x:0,y:0},{id:3,s:'O',x:0,y:-90},{id:4,s:'O',x:90,y:0},{id:5,s:'O',x:0,y:90},{id:6,s:'O',x:-90,y:0}],
[{f:1,t:6,o:1},{f:2,t:3,o:2},{f:2,t:4,o:2},{f:2,t:5,o:2},{f:2,t:6,o:1}],['окислители']);
M('HF','Фторид водорода','Hydrogen fluoride','inorganic',1,
'Плавиковая кислота в растворе. Используется для травления стекла.',
[{id:1,s:'H',x:-40,y:0},{id:2,s:'F',x:40,y:0}],
[{f:1,t:2,o:1}],['галогены']);
M('Br2','Бром','Bromine','inorganic',1,
'Единственный жидкий неметалл при комнатной температуре. Сильный окислитель.',
[{id:1,s:'Br',x:-50,y:0},{id:2,s:'Br',x:50,y:0}],
[{f:1,t:2,o:1}],['галогены']);
M('AgCl','Хлорид серебра','Silver chloride','inorganic',1,
'Белый осадок, нерастворимый в воде. Качественная реакция на ионы Cl⁻.',
[{id:1,s:'Ag',x:-50,y:0},{id:2,s:'Cl',x:50,y:0}],
[{f:1,t:2,o:1}],['соли','осадки']);
M('NH4Cl','Хлорид аммония','Ammonium chloride','inorganic',2,
'Нашатырь. Применяется в медицине, пайке металлов, как удобрение.',
[{id:1,s:'N',x:0,y:0},{id:2,s:'H',x:-80,y:0},{id:3,s:'H',x:0,y:-80},{id:4,s:'H',x:80,y:0},{id:5,s:'H',x:0,y:80},{id:6,s:'Cl',x:100,y:100}],
[{f:1,t:2,o:1},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:1,t:5,o:1}],['соли','азот']);
M('BaSO4','Сульфат бария','Barium sulfate','inorganic',2,
'Нерастворимый белый осадок. Используется в рентгенодиагностике.',
[{id:1,s:'S',x:0,y:0},{id:2,s:'O',x:0,y:-85},{id:3,s:'O',x:85,y:0},{id:4,s:'O',x:0,y:85},{id:5,s:'O',x:-85,y:0},{id:6,s:'Ba',x:150,y:0}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:1,t:5,o:2},{f:3,t:6,o:1}],['соли','осадки']);
M('CuSO4','Сульфат меди(II)','Copper(II) sulfate','inorganic',2,
'Медный купорос в гидратированной форме. Фунгицид, применяется в химическом анализе.',
[{id:1,s:'S',x:0,y:0},{id:2,s:'O',x:0,y:-85},{id:3,s:'O',x:85,y:0},{id:4,s:'O',x:0,y:85},{id:5,s:'O',x:-85,y:0},{id:6,s:'Cu',x:150,y:0}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:1,t:5,o:2},{f:3,t:6,o:1}],['соли','металлы']);
M('Na2SO4','Сульфат натрия','Sodium sulfate','inorganic',2,
'Глауберова соль в гидратированной форме. Применяется в стекольной промышленности.',
[{id:1,s:'S',x:0,y:0},{id:2,s:'O',x:0,y:-80},{id:3,s:'O',x:80,y:0},{id:4,s:'O',x:0,y:80},{id:5,s:'O',x:-80,y:0},{id:6,s:'Na',x:-160,y:0},{id:7,s:'Na',x:160,y:0}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:1,t:5,o:2},{f:5,t:6,o:1},{f:3,t:7,o:1}],['соли']);
M('FeCl3','Хлорид железа(III)','Iron(III) chloride','inorganic',2,
'Применяется как коагулянт при очистке воды, как катализатор.',
[{id:1,s:'Fe',x:0,y:0},{id:2,s:'Cl',x:-90,y:0},{id:3,s:'Cl',x:45,y:-78},{id:4,s:'Cl',x:45,y:78}],
[{f:1,t:2,o:1},{f:1,t:3,o:1},{f:1,t:4,o:1}],['металлы','соли']);
M('H3PO4','Фосфорная кислота','Phosphoric acid','inorganic',2,
'Трёхосновная кислота. Применяется в пищевой промышленности (Е338), удобрениях.',
[{id:1,s:'P',x:0,y:0},{id:2,s:'O',x:0,y:-90},{id:3,s:'O',x:90,y:45},{id:4,s:'O',x:-90,y:45},{id:5,s:'O',x:0,y:90},{id:6,s:'H',x:90,y:120},{id:7,s:'H',x:-90,y:120},{id:8,s:'H',x:80,y:-90}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:1,t:5,o:1},{f:3,t:6,o:1},{f:4,t:7,o:1},{f:2,t:8,o:1}],['кислоты']);
M('CaCO3','Карбонат кальция','Calcium carbonate','inorganic',1,
'Мел, известняк, мрамор. Основа раковин и костей многих организмов.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'O',x:0,y:-80},{id:3,s:'O',x:70,y:55},{id:4,s:'O',x:-70,y:55},{id:5,s:'Ca',x:130,y:55}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:3,t:5,o:1}],['соли']);
M('K2SO4','Сульфат калия','Potassium sulfate','inorganic',2,
'Калийное удобрение. Применяется в сельском хозяйстве.',
[{id:1,s:'S',x:0,y:0},{id:2,s:'O',x:0,y:-80},{id:3,s:'O',x:80,y:0},{id:4,s:'O',x:0,y:80},{id:5,s:'O',x:-80,y:0},{id:6,s:'K',x:-160,y:0},{id:7,s:'K',x:160,y:0}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:1,t:5,o:2},{f:5,t:6,o:1},{f:3,t:7,o:1}],['соли','удобрения']);
M('HNO2','Азотистая кислота','Nitrous acid','inorganic',2,
'Неустойчивая слабая кислота. Реагент в реакциях диазотирования.',
[{id:1,s:'H',x:-80,y:0},{id:2,s:'O',x:0,y:0},{id:3,s:'N',x:80,y:0},{id:4,s:'O',x:160,y:0}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2}],['кислоты','азот']);
M('AlCl3','Хлорид алюминия','Aluminium chloride','inorganic',2,
'Катализатор реакций Фриделя-Крафтса. Применяется в органическом синтезе.',
[{id:1,s:'Al',x:0,y:0},{id:2,s:'Cl',x:-90,y:0},{id:3,s:'Cl',x:45,y:-78},{id:4,s:'Cl',x:45,y:78}],
[{f:1,t:2,o:1},{f:1,t:3,o:1},{f:1,t:4,o:1}],['металлы','соли']);
// === ORGANIC ===
M('C6H6','Бензол','Benzene','organic',2,
'Простейший арен. Ароматическое кольцо с делокализованными π-электронами.',
[{id:1,s:'C',x:80,y:0},{id:2,s:'C',x:40,y:-69},{id:3,s:'C',x:-40,y:-69},{id:4,s:'C',x:-80,y:0},{id:5,s:'C',x:-40,y:69},{id:6,s:'C',x:40,y:69}],
[{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1}],['арены','циклические']);
M('C7H8','Толуол','Toluene','organic',2,
'Бензол с метильной группой. Растворитель, сырьё для синтеза красителей.',
[{id:1,s:'C',x:80,y:0},{id:2,s:'C',x:40,y:-69},{id:3,s:'C',x:-40,y:-69},{id:4,s:'C',x:-80,y:0},{id:5,s:'C',x:-40,y:69},{id:6,s:'C',x:40,y:69},{id:7,s:'C',x:170,y:0}],
[{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1},{f:1,t:7,o:1}],['арены']);
M('C6H5OH','Фенол','Phenol','organic',2,
'Карболовая кислота. Применяется как антисептик и в производстве полимеров.',
[{id:1,s:'C',x:80,y:0},{id:2,s:'C',x:40,y:-69},{id:3,s:'C',x:-40,y:-69},{id:4,s:'C',x:-80,y:0},{id:5,s:'C',x:-40,y:69},{id:6,s:'C',x:40,y:69},{id:7,s:'O',x:170,y:0},{id:8,s:'H',x:240,y:0}],
[{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1},{f:1,t:7,o:1},{f:7,t:8,o:1}],['фенолы','арены']);
M('C6H5NH2','Анилин','Aniline','organic',2,
'Аминобензол. Исходное вещество для синтеза красителей и лекарств.',
[{id:1,s:'C',x:80,y:0},{id:2,s:'C',x:40,y:-69},{id:3,s:'C',x:-40,y:-69},{id:4,s:'C',x:-80,y:0},{id:5,s:'C',x:-40,y:69},{id:6,s:'C',x:40,y:69},{id:7,s:'N',x:170,y:0}],
[{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1},{f:1,t:7,o:1}],['амины','арены']);
M('C3H8O3','Глицерин','Glycerol','organic',2,
'Трёхатомный спирт. Компонент жиров, используется в косметике и фармацевтике.',
[{id:1,s:'C',x:-100,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:100,y:0},{id:4,s:'O',x:-100,y:80},{id:5,s:'O',x:0,y:80},{id:6,s:'O',x:100,y:80}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:1,t:4,o:1},{f:2,t:5,o:1},{f:3,t:6,o:1}],['спирты','липиды']);
M('CH2O','Формальдегид','Formaldehyde','organic',1,
'Простейший альдегид. Консервант, используется в производстве пластмасс.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'O',x:0,y:-80},{id:3,s:'H',x:-70,y:50},{id:4,s:'H',x:70,y:50}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1}],['альдегиды']);
M('CH3CHO','Уксусный альдегид','Acetaldehyde','organic',1,
'Этаналь. Промежуточный продукт обмена веществ, образуется при окислении этанола.',
[{id:1,s:'C',x:-60,y:0},{id:2,s:'C',x:60,y:0},{id:3,s:'O',x:60,y:-85}],
[{f:1,t:2,o:1},{f:2,t:3,o:2}],['альдегиды']);
M('C6H12O6','Глюкоза','Glucose','organic',3,
'Основной источник энергии клеток. Продукт фотосинтеза, субстрат гликолиза.',
[{id:1,s:'C',x:80,y:0},{id:2,s:'C',x:40,y:-69},{id:3,s:'C',x:-40,y:-69},{id:4,s:'C',x:-80,y:0},{id:5,s:'C',x:-40,y:69},{id:6,s:'O',x:40,y:69},{id:7,s:'O',x:155,y:0},{id:8,s:'O',x:40,y:-140},{id:9,s:'O',x:-40,y:-140},{id:10,s:'O',x:-155,y:0},{id:11,s:'C',x:-40,y:150},{id:12,s:'O',x:-40,y:230}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:1},{f:4,t:5,o:1},{f:5,t:6,o:1},{f:6,t:1,o:1},{f:1,t:7,o:1},{f:2,t:8,o:1},{f:3,t:9,o:1},{f:4,t:10,o:1},{f:5,t:11,o:1},{f:11,t:12,o:1}],['углеводы','гликолиз']);
M('C3H6O3','Молочная кислота','Lactic acid','organic',2,
'Продукт анаэробного гликолиза. Накапливается в мышцах при физической нагрузке.',
[{id:1,s:'C',x:-80,y:0},{id:2,s:'O',x:-80,y:80},{id:3,s:'C',x:0,y:0},{id:4,s:'C',x:80,y:0},{id:5,s:'O',x:80,y:-80},{id:6,s:'O',x:160,y:0}],
[{f:3,t:1,o:1},{f:1,t:2,o:1},{f:3,t:4,o:1},{f:4,t:5,o:2},{f:4,t:6,o:1}],['обмен веществ','органические кислоты']);
M('C3H4O3','Пировиноградная кислота','Pyruvic acid','organic',2,
'Пируват — ключевой метаболит. Связывает гликолиз и цикл Кребса.',
[{id:1,s:'C',x:-80,y:0},{id:2,s:'O',x:-80,y:-80},{id:3,s:'C',x:0,y:0},{id:4,s:'C',x:80,y:0},{id:5,s:'O',x:80,y:-80},{id:6,s:'O',x:160,y:0}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:3,t:4,o:1},{f:4,t:5,o:2},{f:4,t:6,o:1}],['гликолиз','обмен веществ']);
// === AMINO ACIDS (all 20) ===
// Standard backbone: N(1,-120,0) Cα(2,0,0) C(3,90,-55) O(4,135,-130) O(5,190,-10)
// bonds: N-Cα(1-2,1), Cα-C(2-3,1), C=O(3-4,2), C-OH(3-5,1)
// Side chains from Cα downward
M('C3H7NO2','Аланин','Alanine','biomolecule',2,
'Заменимая аминокислота. Входит в состав большинства белков.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1}],['аминокислоты']);
M('C5H11NO2','Валин','Valine','biomolecule',2,
'Незаменимая аминокислота. Необходима для синтеза белков мышечной ткани.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:-60,y:165},{id:8,s:'C',x:60,y:165}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:6,t:8,o:1}],['аминокислоты','незаменимые']);
M('C6H13NO2','Лейцин','Leucine','biomolecule',2,
'Незаменимая аминокислота. Стимулирует синтез белка, особенно важна для мышц.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:170},{id:8,s:'C',x:-65,y:240},{id:9,s:'C',x:65,y:240}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:7,t:9,o:1}],['аминокислоты','незаменимые']);
M('C6H13NO2','Изолейцин','Isoleucine','biomolecule',3,
'Незаменимая аминокислота. Изомер лейцина, важен для синтеза гемоглобина.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:-60,y:160},{id:8,s:'C',x:60,y:160},{id:9,s:'C',x:60,y:235}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:6,t:8,o:1},{f:8,t:9,o:1}],['аминокислоты','незаменимые']);
M('C5H9NO2','Пролин','Proline','biomolecule',3,
'Иминокислота. Создаёт жёсткие изгибы в белковой цепи (в коллагене ≈12%).',
[{id:1,s:'N',x:-80,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:80,y:-55},{id:4,s:'O',x:120,y:-130},{id:5,s:'O',x:175,y:-10},{id:6,s:'C',x:-10,y:90},{id:7,s:'C',x:-90,y:140},{id:8,s:'C',x:-150,y:60}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:8,t:1,o:1}],['аминокислоты','иминокислоты']);
M('C3H7NO3','Серин','Serine','biomolecule',2,
'Заменимая аминокислота. Входит в активные центры многих ферментов.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'O',x:0,y:170}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1}],['аминокислоты']);
M('C4H9NO3','Треонин','Threonine','biomolecule',2,
'Незаменимая аминокислота. Участвует в синтезе иммуноглобулинов.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'O',x:-65,y:160},{id:8,s:'C',x:65,y:160}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:6,t:8,o:1}],['аминокислоты','незаменимые']);
M('C3H7NO2S','Цистеин','Cysteine','biomolecule',2,
'Серосодержащая аминокислота. Образует дисульфидные мостики, стабилизируя белки.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'S',x:0,y:175}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1}],['аминокислоты','серосодержащие']);
M('C5H11NO2S','Метионин','Methionine','biomolecule',2,
'Незаменимая серосодержащая аминокислота. Донор метильных групп (метилирование).',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:165},{id:8,s:'S',x:0,y:245},{id:9,s:'C',x:80,y:300}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:8,t:9,o:1}],['аминокислоты','незаменимые','серосодержащие']);
M('C4H7NO4','Аспарагиновая кислота','Aspartic acid','biomolecule',2,
'Кислая аминокислота. Участвует в цикле мочевины и синтезе пуринов.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:170},{id:8,s:'O',x:-65,y:240},{id:9,s:'O',x:65,y:240}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:2},{f:7,t:9,o:1}],['аминокислоты','кислые']);
M('C5H9NO4','Глутаминовая кислота','Glutamic acid','biomolecule',2,
'Кислая аминокислота, нейромедиатор. Предшественник ГАМК и глутамина.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:165},{id:8,s:'C',x:0,y:240},{id:9,s:'O',x:-65,y:310},{id:10,s:'O',x:65,y:310}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:8,t:9,o:2},{f:8,t:10,o:1}],['аминокислоты','кислые','нейромедиаторы']);
M('C4H8N2O3','Аспарагин','Asparagine','biomolecule',2,
'Заменимая аминокислота. Амид аспарагиновой кислоты. Важен для нервной системы.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:170},{id:8,s:'O',x:-70,y:240},{id:9,s:'N',x:70,y:240}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:2},{f:7,t:9,o:1}],['аминокислоты']);
M('C5H10N2O3','Глутамин','Glutamine','biomolecule',2,
'Наиболее распространённая свободная аминокислота в крови. Транспортёр азота.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:165},{id:8,s:'C',x:0,y:240},{id:9,s:'O',x:-70,y:310},{id:10,s:'N',x:70,y:310}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:8,t:9,o:2},{f:8,t:10,o:1}],['аминокислоты']);
M('C6H14N2O2','Лизин','Lysine','biomolecule',2,
'Незаменимая основная аминокислота. Необходима для роста и восстановления тканей.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:165},{id:8,s:'C',x:0,y:240},{id:9,s:'C',x:0,y:315},{id:10,s:'N',x:0,y:390}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:8,t:9,o:1},{f:9,t:10,o:1}],['аминокислоты','основные','незаменимые']);
M('C6H14N4O2','Аргинин','Arginine','biomolecule',3,
'Условно незаменимая аминокислота. Предшественник оксида азота NO. Входит в цикл мочевины.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:165},{id:8,s:'C',x:0,y:240},{id:9,s:'N',x:0,y:315},{id:10,s:'C',x:0,y:390},{id:11,s:'N',x:-65,y:460},{id:12,s:'N',x:65,y:460}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:8,t:9,o:1},{f:9,t:10,o:1},{f:10,t:11,o:2},{f:10,t:12,o:1}],['аминокислоты','основные','незаменимые']);
M('C6H9N3O2','Гистидин','Histidine','biomolecule',3,
'Незаменимая аминокислота с имидазольным кольцом. Предшественник гистамина.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:165},{id:8,s:'C',x:50,y:230},{id:9,s:'N',x:90,y:310},{id:10,s:'C',x:0,y:360},{id:11,s:'N',x:-90,y:310},{id:12,s:'C',x:-50,y:230}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:7,t:12,o:1},{f:8,t:9,o:2},{f:9,t:10,o:1},{f:10,t:11,o:1},{f:11,t:12,o:2}],['аминокислоты','основные','незаменимые']);
M('C9H11NO2','Фенилаланин','Phenylalanine','biomolecule',3,
'Незаменимая ароматическая аминокислота. Предшественник тирозина, дофамина.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:165},{id:8,s:'C',x:50,y:225},{id:9,s:'C',x:90,y:305},{id:10,s:'C',x:50,y:385},{id:11,s:'C',x:-50,y:385},{id:12,s:'C',x:-90,y:305},{id:13,s:'C',x:-50,y:225}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:7,t:13,o:1},{f:8,t:9,o:2},{f:9,t:10,o:1},{f:10,t:11,o:2},{f:11,t:12,o:1},{f:12,t:13,o:2}],['аминокислоты','ароматические','незаменимые']);
M('C9H11NO3','Тирозин','Tyrosine','biomolecule',3,
'Заменимая ароматическая аминокислота. Предшественник дофамина, адреналина, тироксина.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:165},{id:8,s:'C',x:50,y:225},{id:9,s:'C',x:90,y:305},{id:10,s:'C',x:50,y:385},{id:11,s:'C',x:-50,y:385},{id:12,s:'C',x:-90,y:305},{id:13,s:'C',x:-50,y:225},{id:14,s:'O',x:130,y:385}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:7,t:13,o:1},{f:8,t:9,o:2},{f:9,t:10,o:1},{f:10,t:11,o:2},{f:11,t:12,o:1},{f:12,t:13,o:2},{f:10,t:14,o:1}],['аминокислоты','ароматические']);
M('C11H12N2O2','Триптофан','Tryptophan','biomolecule',3,
'Незаменимая аминокислота. Предшественник серотонина и мелатонина.',
[{id:1,s:'N',x:-120,y:0},{id:2,s:'C',x:0,y:0},{id:3,s:'C',x:90,y:-55},{id:4,s:'O',x:135,y:-130},{id:5,s:'O',x:190,y:-10},{id:6,s:'C',x:0,y:90},{id:7,s:'C',x:0,y:165},{id:8,s:'N',x:-50,y:235},{id:9,s:'C',x:50,y:235},{id:10,s:'C',x:70,y:315},{id:11,s:'C',x:0,y:375},{id:12,s:'C',x:-70,y:315}],
[{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:3,t:5,o:1},{f:2,t:6,o:1},{f:6,t:7,o:1},{f:7,t:8,o:1},{f:7,t:9,o:2},{f:8,t:12,o:1},{f:9,t:10,o:1},{f:10,t:11,o:2},{f:11,t:12,o:1}],['аминокислоты','ароматические','незаменимые']);
// Additional biomolecules
M('C21H28N7O14P2','АТФ','ATP','biomolecule',3,
'Аденозинтрифосфат — универсальный носитель энергии в клетке. «Энергетическая валюта».',
[],[],['энергетический обмен','нуклеотиды']);
M('C10H14N5O7P','АМФ','AMP','biomolecule',3,
'Аденозинмонофосфат. Образуется при гидролизе АТФ или АДФ.',
[],[],['нуклеотиды','энергетический обмен']);
M('C10H15N5O10P2','АДФ','ADP','biomolecule',3,
'Аденозиндифосфат. Промежуточный продукт между АТФ и АМФ.',
[],[],['энергетический обмен','нуклеотиды']);
M('C5H5N5','Аденин','Adenine','biomolecule',3,
'Пуриновое азотистое основание. Входит в состав ДНК, РНК, АТФ.',
[],[],['нуклеотиды','ДНК','РНК']);
M('C4H5N3O','Цитозин','Cytosine','biomolecule',3,
'Пиримидиновое азотистое основание. Входит в состав ДНК и РНК.',
[],[],['нуклеотиды','ДНК','РНК']);
M('C5H5N5O','Гуанин','Guanine','biomolecule',3,
'Пуриновое азотистое основание. Входит в состав ДНК и РНК.',
[],[],['нуклеотиды','ДНК','РНК']);
M('C5H6N2O2','Тимин','Thymine','biomolecule',3,
'Пиримидиновое основание, входящее только в ДНК. Комплементарно аденину.',
[],[],['нуклеотиды','ДНК']);
M('C4H4N2O2','Урацил','Uracil','biomolecule',3,
'Пиримидиновое основание РНК. Заменяет тимин. Комплементарно аденину.',
[],[],['нуклеотиды','РНК']);
M('C16H32O2','Пальмитиновая кислота','Palmitic acid','biomolecule',2,
'Насыщенная жирная кислота C16. Основной компонент животных жиров и пальмового масла.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'O',x:0,y:-80},{id:3,s:'O',x:80,y:0},{id:4,s:'C',x:-80,y:0},{id:5,s:'C',x:-160,y:0},{id:6,s:'C',x:-240,y:0}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:4,t:5,o:1},{f:5,t:6,o:1}],['жирные кислоты','липиды']);
M('C18H34O2','Олеиновая кислота','Oleic acid','biomolecule',3,
'Ненасыщенная жирная кислота C18:1. Основной компонент оливкового масла.',
[{id:1,s:'C',x:0,y:0},{id:2,s:'O',x:0,y:-80},{id:3,s:'O',x:80,y:0},{id:4,s:'C',x:-80,y:0},{id:5,s:'C',x:-160,y:0},{id:6,s:'C',x:-225,y:-45},{id:7,s:'C',x:-290,y:0}],
[{f:1,t:2,o:2},{f:1,t:3,o:1},{f:1,t:4,o:1},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:7,o:1}],['жирные кислоты','ненасыщенные','липиды']);
console.log('✓ Plan Phase 1: Extended molecules seeded');
}
/* ── Plan Phase 1: Extended reactions ── */
{
const insR = db.prepare(`INSERT INTO bio_reactions (equation,name_ru,type,description,reactant_ids,product_ids,conditions,energy_kj,topic_tags)
SELECT ?,?,?,?,?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM bio_reactions WHERE name_ru=?)`);
const mid = f => db.prepare('SELECT id FROM bio_molecules WHERE formula=? LIMIT 1').get(f)?.id;
const midN = n => db.prepare('SELECT id FROM bio_molecules WHERE name_ru=? LIMIT 1').get(n)?.id;
const R = (eq,name,type,desc,reacts,prods,cond,energy,tags) => {
const ri = JSON.stringify(reacts.map(f => mid(f) || midN(f)).filter(Boolean));
const pi = JSON.stringify(prods.map(f => mid(f) || midN(f)).filter(Boolean));
insR.run(eq,name,type,desc,ri,pi,cond,energy,JSON.stringify(tags),name);
};
R('2H2O → 2H2 + O2','Гидролиз воды','decomposition',
'Электролиз воды — разложение на водород и кислород',
['H2O'],['H2','O2'],'электролиз, 1500°C',285.8,['вода']);
R('Ca + 2H2O → Ca(OH)2 + H2↑','Взаимодействие кальция с водой','exchange',
'Реакция кальция с водой с образованием гашёной извести',
['H2O'],['Ca(OH)2','H2'],null,-635,['металлы']);
R('HCl + NaOH → NaCl + H2O','Нейтрализация HCl и NaOH','acid_base',
'Реакция нейтрализации сильной кислоты и сильного основания',
['HCl','NaOH'],['NaCl','H2O'],null,-57.3,['кислоты-основания']);
R('C2H5OH + 3O2 → 2CO2 + 3H2O','Горение этанола','combustion',
'Горение этилового спирта с выделением углекислого газа и воды',
['C2H5OH','O2'],['CO2','H2O'],null,-1366,['горение','спирты']);
R('C3H8 + 5O2 → 3CO2 + 4H2O','Горение пропана','combustion',
'Горение пропана — реакция сгорания в бытовых горелках',
['C3H8','O2'],['CO2','H2O'],null,-2220,['горение','алканы']);
R('2C6H6 + 15O2 → 12CO2 + 6H2O','Горение бензола','combustion',
'Горение бензола с образованием углекислого газа и воды',
['C6H6','O2'],['CO2','H2O'],null,-3267,['горение','арены']);
R('C12H22O11 + H2O → 2C6H12O6','Гидролиз сахарозы','hydrolysis',
'Инвертный сахар — расщепление сахарозы до глюкозы и фруктозы',
['C12H22O11','H2O'],['C6H12O6'],'H⁺, фермент',16.5,['углеводы']);
R('C6H12O6 → 2C3H6O3','Гликолиз (суммарная реакция)','exchange',
'Анаэробное расщепление глюкозы до молочной кислоты',
['C6H12O6'],['C3H6O3'],'ферменты',-219,['гликолиз','обмен веществ']);
R('АДФ + H3PO4 → АТФ + H2O','Синтез АТФ','synthesis',
'Фосфорилирование АДФ с образованием АТФ',
['C10H15N5O10P2','H3PO4'],['C21H28N7O14P2','H2O'],'АТФ-синтаза',-30.5,['энергетический обмен']);
R('АТФ + H2O → АДФ + H3PO4','Гидролиз АТФ','hydrolysis',
'Высвобождение энергии из АТФ путём гидролиза',
['C21H28N7O14P2','H2O'],['C10H15N5O10P2','H3PO4'],'АТФаза',-30.5,['энергетический обмен']);
R('C2H5OH → C2H4 + H2O','Дегидратация спирта','decomposition',
'Получение этилена из этанола путём отщепления воды',
['C2H5OH'],['C2H4','H2O'],'H₂SO₄, 170°C',null,['спирты','алкены']);
R('3CO2 + 3H2O → C3H6O3 + 3O2','Восстановление CO2 (цикл Кальвина)','synthesis',
'Цикл Кальвина — темновая фаза фотосинтеза',
['CO2','H2O'],['C3H6O3'],'свет, хлоропласты',480,['фотосинтез']);
R('2H2O2 → 2H2O + O2','Разложение пероксида водорода','decomposition',
'Каталитическое разложение пероксида водорода каталазой',
['H2O2'],['H2O','O2'],'каталаза',-98,['дыхание']);
R('C3H4O3 → CH3CHO + CO2','Декарбоксилирование пировиноградной кислоты','decomposition',
'Пируват → ацетальдегид + CO₂. Первый шаг спиртового брожения.',
['C3H4O3'],['CH3CHO','CO2'],'ПВК-декарбоксилаза',null,['гликолиз']);
R('C3H4O3 + H2O → C3H6O3','Образование молочной кислоты','synthesis',
'Молочнокислое брожение — восстановление пирувата до лактата',
['C3H4O3','H2O'],['C3H6O3'],'лактатдегидрогеназа',null,['брожение','гликолиз']);
R('2SO2 + O2 → 2SO3','Окисление серы до SO3','redox',
'Контактный метод получения триоксида серы',
['SO2','O2'],['SO3'],'V₂O₅, 450-500°C',-98,['неорганика']);
R('SO3 + H2O → H2SO4','Получение H2SO4','synthesis',
'Серная кислота из триоксида серы',
['SO3','H2O'],['H2SO4'],null,-130,['неорганика']);
R('2H2O → H3O+ + OH-','Диссоциация воды','decomposition',
'Аутопротолиз воды — равновесная диссоциация',
['H2O'],['H2O'],null,55.8,['вода','pH']);
R('(Крахмал) + H2O → C6H12O6','Гидролиз крахмала','hydrolysis',
'Расщепление полисахарида до глюкозы при пищеварении',
['H2O'],['C6H12O6'],'амилаза',null,['углеводы','пищеварение']);
R('C6H12O6 + 6O2 → 6CO2 + 6H2O','Клеточное дыхание','redox',
'Полное окисление глюкозы в митохондриях с синтезом АТФ',
['C6H12O6','O2'],['CO2','H2O'],'митохондрии',-2808,['дыхание','обмен веществ']);
R('2C2H5NO2 → C4H8N2O3 + H2O','Образование пептидной связи','synthesis',
'Конденсация двух молекул глицина с образованием дипептида и воды',
['C2H5NO2'],['C4H8N2O3','H2O'],'рибосома',17,['белки','синтез белка']);
R('(Белок) + H2O → аминокислоты','Гидролиз белка','hydrolysis',
'Расщепление пептидных связей белка до аминокислот',
['H2O'],['C2H5NO2'],'протеазы',null,['белки','пищеварение']);
R('C16H32O2 + 23O2 → 16CO2 + 16H2O','Β-окисление пальмитиновой кислоты','redox',
'Полное окисление пальмитиновой кислоты в митохондриях',
['C16H32O2','O2'],['CO2','H2O'],'митохондрии, β-оксидаза',-9790,['липиды','энергетический обмен']);
console.log('✓ Plan Phase 1: Extended reactions seeded');
}
/* ── Plan Phase 1: Extended challenges ── */
{
const insChal2 = db.prepare(`INSERT INTO bio_challenges (title,description,type,target_formula,hint,xp_reward,difficulty,topic_tag,order_n,data_json)
SELECT ?,?,?,?,?,?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM bio_challenges WHERE title=?)`);
const mid2 = f => db.prepare('SELECT id FROM bio_molecules WHERE formula=? LIMIT 1').get(f)?.id;
const mname2 = f => db.prepare('SELECT name_ru FROM bio_molecules WHERE formula=? LIMIT 1').get(f)?.name_ru;
// BUILD challenges (10 new)
[
{ title:'Построй: HF', target:'HF', hint:'Водород + Фтор', xp:35, d:1, t:'галогены', n:19 },
{ title:'Построй: Br2', target:'Br2', hint:'Два атома брома', xp:35, d:1, t:'галогены', n:20 },
{ title:'Построй: NaOH', target:'NaOH', hint:'Натрий-кислород-водород', xp:40, d:1, t:'кислоты-основания',n:21 },
{ title:'Построй: NH4+', target:'NH4Cl', hint:'Азот + 4 водорода (катион аммония)', xp:40, d:1, t:'азот', n:22 },
{ title:'Построй: бензол C6H6', target:'C6H6', hint:'Шесть углеродов в кольце с чередующимися двойными связями', xp:80, d:3, t:'арены', n:23 },
{ title:'Построй: уксусный альдегид',target:'CH3CHO', hint:'Этаналь: CH₃-CHO', xp:50, d:2, t:'альдегиды', n:24 },
{ title:'Построй: глицерин', target:'C3H8O3', hint:'Трёхатомный спирт — C₃H₅(OH)₃', xp:60, d:2, t:'спирты', n:25 },
{ title:'Построй: формальдегид CH2O',target:'CH2O', hint:'Простейший альдегид — метаналь', xp:35, d:1, t:'альдегиды', n:26 },
{ title:'Построй: CaCO3', target:'CaCO3', hint:'Кальций + карбонат-ион', xp:45, d:2, t:'соли', n:27 },
{ title:'Построй: KOH', target:'KOH', hint:'Калий + гидроксил', xp:35, d:1, t:'кислоты-основания',n:28 },
].forEach(c => {
const mol_id = mid2(c.target);
insChal2.run(c.title, `Построй молекулу: ${mname2(c.target) || c.target}`, 'build', c.target, c.hint, c.xp, c.d, c.t, c.n,
JSON.stringify({ mol_id }), c.title);
});
// IDENTIFY challenges (15 new)
[
{ title:'Определи: этанол', f:'C2H5OH', choices:['Этанол','Этаналь','Метанол','Пропанол'], xp:40, d:2, t:'спирты', n:29 },
{ title:'Определи: этилен', f:'C2H4', choices:['Этилен','Этан','Ацетилен','Метан'], xp:40, d:2, t:'алкены', n:30 },
{ title:'Определи: уксусная кислота', f:'CH3COOH', choices:['Уксусная кислота','Молочная кислота','Пировиноградная к-та','Щавелевая к-та'], xp:50, d:2, t:'органические кислоты', n:31 },
{ title:'Определи: серная кислота', f:'H2SO4', choices:['Серная кислота','Соляная кислота','Азотная кислота','Фосфорная кислота'], xp:40, d:1, t:'кислоты', n:32 },
{ title:'Определи: NaOH', f:'NaOH', choices:['Гидроксид натрия','Карбонат натрия','Хлорид натрия','Гидрокарбонат натрия'], xp:30, d:1, t:'кислоты-основания', n:33 },
{ title:'Определи: глюкоза', f:'C6H12O6', choices:['Глюкоза','Фруктоза','Сахароза','Рибоза'], xp:60, d:2, t:'углеводы', n:34 },
{ title:'Определи: молочная кислота', f:'C3H6O3', choices:['Молочная кислота','Уксусная кислота','Глицерин','Пировиноградная к-та'], xp:55, d:2, t:'обмен веществ', n:35 },
{ title:'Определи: аланин', f:'C3H7NO2', choices:['Аланин','Глицин','Серин','Валин'], xp:50, d:2, t:'аминокислоты', n:36 },
{ title:'Определи: серин', f:'C3H7NO3', choices:['Серин','Аланин','Цистеин','Треонин'], xp:60, d:3, t:'аминокислоты', n:37 },
{ title:'Определи: бензол', f:'C6H6', choices:['Бензол','Гексан','Циклогексан','Толуол'], xp:60, d:2, t:'арены', n:38 },
{ title:'Определи: глицерин', f:'C3H8O3', choices:['Глицерин','Этанол','Пропанол','Сорбит'], xp:55, d:2, t:'липиды', n:39 },
{ title:'Определи: азотная кислота', f:'HNO3', choices:['Азотная кислота','Азотистая кислота','Серная кислота','Соляная кислота'], xp:35, d:1, t:'кислоты', n:40 },
{ title:'Определи: H2O2', f:'H2O2', choices:['Пероксид водорода','Вода','Озон','Гидроксид'], xp:35, d:1, t:'вода', n:41 },
{ title:'Определи: пировиноградная к-та', f:'C3H4O3', choices:['Пировиноградная кислота','Молочная кислота','Уксусная кислота','Янтарная кислота'], xp:65, d:3, t:'гликолиз', n:42 },
{ title:'Определи: пропан', f:'C3H8', choices:['Пропан','Этан','Метан','Бутан'], xp:35, d:1, t:'алканы', n:43 },
].forEach(c => {
const mol_id = mid2(c.f);
insChal2.run(c.title, `Определи молекулу по структурной формуле`, 'identify', c.f, null, c.xp, c.d, c.t, c.n,
JSON.stringify({ mol_id, choices: c.choices }), c.title);
});
// FORMULA challenges (15 new)
[
{ title:'Формула бензола', mol_name:'бензола', f:'C6H6', choices:['C6H6','C6H12','C6H5OH','C7H8'], xp:55, d:2, t:'арены', n:44 },
{ title:'Формула серной кислоты', mol_name:'серной кислоты', f:'H2SO4', choices:['H2SO4','H2SO3','H2S','HSO4'], xp:35, d:1, t:'кислоты', n:45 },
{ title:'Формула этилена', mol_name:'этилена', f:'C2H4', choices:['C2H4','C2H6','C2H2','CH4'], xp:40, d:2, t:'алкены', n:46 },
{ title:'Формула этанола', mol_name:'этанола', f:'C2H5OH', choices:['C2H5OH','CH3OH','C3H7OH','C2H4O'], xp:40, d:2, t:'спирты', n:47 },
{ title:'Формула пероксида водорода', mol_name:'пероксида водорода', f:'H2O2', choices:['H2O2','H2O','HO','HO2'], xp:35, d:1, t:'вода', n:48 },
{ title:'Формула уксусной кислоты', mol_name:'уксусной кислоты', f:'CH3COOH', choices:['CH3COOH','C2H5OH','HCOOH','C3H6O2'], xp:45, d:2, t:'органические кислоты',n:49 },
{ title:'Формула глюкозы', mol_name:'глюкозы', f:'C6H12O6', choices:['C6H12O6','C5H10O5','C12H22O11','C6H10O5'],xp:55, d:2, t:'углеводы', n:50 },
{ title:'Формула аланина', mol_name:'аланина', f:'C3H7NO2', choices:['C3H7NO2','C2H5NO2','C4H9NO2','C3H7NO3'], xp:50, d:2, t:'аминокислоты', n:51 },
{ title:'Формула азотной кислоты', mol_name:'азотной кислоты', f:'HNO3', choices:['HNO3','HNO2','N2O','NO2'], xp:35, d:1, t:'кислоты', n:52 },
{ title:'Формула NH3: аммиак', mol_name:'аммиака', f:'NH3', choices:['NH3','N2H4','NO','NO2'], xp:25, d:1, t:'азот', n:53 },
{ title:'Формула фосфорной кислоты', mol_name:'фосфорной кислоты', f:'H3PO4', choices:['H3PO4','H2PO4','HPO3','H3PO3'], xp:50, d:2, t:'кислоты', n:54 },
{ title:'Формула сероводорода', mol_name:'сероводорода', f:'H2S', choices:['H2S','HS','H2SO4','SO2'], xp:35, d:1, t:'неорганика', n:55 },
{ title:'Формула молочной кислоты', mol_name:'молочной кислоты', f:'C3H6O3', choices:['C3H6O3','C3H4O3','C2H4O2','C4H8O4'], xp:55, d:2, t:'обмен веществ', n:56 },
{ title:'Формула пропана', mol_name:'пропана', f:'C3H8', choices:['C3H8','C3H6','C2H6','C4H10'], xp:35, d:1, t:'алканы', n:57 },
{ title:'Формула пировиноградной кислоты', mol_name:'пировиноградной кислоты', f:'C3H4O3', choices:['C3H4O3','C3H6O3','C2H4O2','C4H6O4'], xp:65, d:3, t:'гликолиз', n:58 },
].forEach(c => {
const mol_name = mname2(c.f) || c.mol_name;
insChal2.run(c.title, `Выбери правильную химическую формулу: ${mol_name}`, 'formula', c.f, null, c.xp, c.d, c.t, c.n,
JSON.stringify({ choices: c.choices, mol_name }), c.title);
});
console.log('✓ Plan Phase 1: Extended challenges seeded');
}
/* ── users.role: expand CHECK to include 'free_student' ─────────────────── */
try {
const uInfo = db.prepare("PRAGMA table_info(users)").all();
// Detect if CHECK already allows free_student by attempting a dry-run (rollback)
const needsMigration = (() => {
try {
db.exec('SAVEPOINT chk_role_test');
db.exec("INSERT INTO users (email,password_hash,name,role) VALUES ('__chk__@x','x','x','free_student')");
db.exec('ROLLBACK TO chk_role_test');
db.exec('RELEASE chk_role_test');
return false; // already works
} catch {
try { db.exec('ROLLBACK TO chk_role_test'); db.exec('RELEASE chk_role_test'); } catch {}
return true;
}
})();
if (needsMigration) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`
CREATE TABLE users_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'student'
CHECK (role IN ('student','teacher','admin','free_student')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT,
xp INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 1,
streak_current INTEGER NOT NULL DEFAULT 0,
streak_best INTEGER NOT NULL DEFAULT 0,
streak_date TEXT,
goal_tier TEXT DEFAULT 'medium',
avatar_frame TEXT DEFAULT 'default',
coins INTEGER NOT NULL DEFAULT 0,
active_title TEXT DEFAULT NULL,
active_theme TEXT DEFAULT NULL,
active_effect TEXT DEFAULT NULL,
token_version INTEGER NOT NULL DEFAULT 0,
lab_experiments INTEGER NOT NULL DEFAULT 0,
lab_reactions INTEGER NOT NULL DEFAULT 0,
is_banned INTEGER NOT NULL DEFAULT 0
)
`);
// Copy only columns that exist in old table
const oldCols = uInfo.map(c => c.name);
const newCols = [
'id','email','password_hash','name','role','created_at','last_login',
'xp','level','streak_current','streak_best','streak_date',
'goal_tier','avatar_frame','coins',
'active_title','active_theme','active_effect',
'token_version','lab_experiments','lab_reactions','is_banned',
].filter(c => oldCols.includes(c));
db.exec(`INSERT INTO users_v2 (${newCols.join(',')}) SELECT ${newCols.join(',')} FROM users`);
db.exec('DROP TABLE users');
db.exec('ALTER TABLE users_v2 RENAME TO users');
db.exec('PRAGMA foreign_keys = ON');
console.log('✓ users.role CHECK updated to include free_student');
} else {
console.log('✓ users.role already supports free_student');
}
} catch (e) {
try { db.exec('PRAGMA foreign_keys = ON'); } catch {}
console.warn('users.role migration skipped:', e.message);
}
/* ── Phase 8.1: Add Z-coordinates to key molecules for 3D mode ─────────── */
{
const getByFormula = db.prepare(
"SELECT id, formula, atoms_json FROM bio_molecules WHERE formula = ? ORDER BY id LIMIT 1"
);
const updateAtoms = db.prepare("UPDATE bio_molecules SET atoms_json = ? WHERE id = ?");
function pj(s) { try { return JSON.parse(s); } catch { return []; } }
// H2O: O at z=0, H1 (left) at z=+30, H2 (right) at z=-30 — angular geometry
const h2o = getByFormula.get('H2O');
if (h2o) {
const a = pj(h2o.atoms_json);
if (!a.some(x => x.z !== undefined)) {
let hIdx = 0;
const updated = a.map(x =>
x.s === 'O' ? { ...x, z: 0 } :
x.s === 'H' ? { ...x, z: hIdx++ === 0 ? 30 : -30 } : x
);
updateAtoms.run(JSON.stringify(updated), h2o.id);
}
}
// NH3: N at z=+20 (apex, pyramidal), H atoms at z=-15 (base)
const nh3 = getByFormula.get('NH3');
if (nh3) {
const a = pj(nh3.atoms_json);
if (!a.some(x => x.z !== undefined)) {
const updated = a.map(x =>
x.s === 'N' ? { ...x, z: 20 } :
x.s === 'H' ? { ...x, z: -15 } : x
);
updateAtoms.run(JSON.stringify(updated), nh3.id);
}
}
// CH4: C at z=0, tetrahedral H — alternate +35/-35
// 2D layout: H1 top, H2 right, H3 left, H4 bottom → z: +35,-35,+35,-35
const ch4 = getByFormula.get('CH4');
if (ch4) {
const a = pj(ch4.atoms_json);
if (!a.some(x => x.z !== undefined)) {
let hIdx = 0;
const hZ = [35, -35, 35, -35];
const updated = a.map(x =>
x.s === 'C' ? { ...x, z: 0 } :
x.s === 'H' ? { ...x, z: hZ[hIdx++] ?? 0 } : x
);
updateAtoms.run(JSON.stringify(updated), ch4.id);
}
}
// C6H6 benzene: planar — all z=0 (explicit)
const c6h6 = getByFormula.get('C6H6');
if (c6h6) {
const a = pj(c6h6.atoms_json);
if (!a.some(x => x.z !== undefined)) {
updateAtoms.run(JSON.stringify(a.map(x => ({ ...x, z: 0 }))), c6h6.id);
}
}
// Amino acids: N (amino group) at z=+30, O atoms at z=-20, C/H at z=0
const aminoAcids = db.prepare(
"SELECT id, atoms_json FROM bio_molecules WHERE category = 'amino_acid'"
).all();
for (const mol of aminoAcids) {
const a = pj(mol.atoms_json);
if (a.some(x => x.z !== undefined)) continue;
let nIdx = 0;
const updated = a.map(x =>
x.s === 'N' ? { ...x, z: nIdx++ === 0 ? 30 : 10 } :
x.s === 'O' ? { ...x, z: -20 } :
{ ...x, z: 0 }
);
updateAtoms.run(JSON.stringify(updated), mol.id);
}
console.log('✓ Phase 8.1: Z-coordinates added to key molecules');
}
/* ── Phase 8.2: Add 2D atom coordinates to molecules with empty atoms_json ── */
{
const getEmpty = db.prepare("SELECT id, formula, name_ru FROM bio_molecules WHERE atoms_json='[]' OR atoms_json IS NULL");
const setAtoms = db.prepare("UPDATE bio_molecules SET atoms_json=?, bonds_json=? WHERE id=?");
// Purine ring scaffold (hexagon fused with pentagon sharing C4-C5 edge)
// Hexagon center ~(0,0), R=56; pentagon below, all heavy-atom ring positions
const PURINE_RING = {
atoms: [
{id:1,s:'N',x:56, y:0}, // N3
{id:2,s:'C',x:28, y:50}, // C4 (junction)
{id:3,s:'C',x:-28, y:50}, // C5 (junction)
{id:4,s:'C',x:-56, y:0}, // C6
{id:5,s:'N',x:-28, y:-50}, // N1
{id:6,s:'C',x:28, y:-50}, // C2
{id:7,s:'N',x:45, y:103}, // N9
{id:8,s:'C',x:0, y:136}, // C8
{id:9,s:'N',x:-45, y:103}, // N7
],
bonds: [
{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1}, // 6-ring
{f:2,t:7,o:1},{f:7,t:8,o:2},{f:8,t:9,o:1},{f:9,t:3,o:2}, // 5-ring
],
};
// Pyrimidine ring scaffold (6-membered flat-bottom hexagon, R=50)
const PYRIM_RING = {
atoms: [
{id:1,s:'C',x:0, y:-50}, // C2 (top)
{id:2,s:'N',x:43, y:-25}, // N3
{id:3,s:'C',x:43, y:25}, // C4
{id:4,s:'C',x:0, y:50}, // C5
{id:5,s:'C',x:-43, y:25}, // C6
{id:6,s:'N',x:-43, y:-25}, // N1
],
bonds: [
{f:6,t:1,o:1},{f:1,t:2,o:1},{f:2,t:3,o:2},{f:3,t:4,o:1},{f:4,t:5,o:2},{f:5,t:6,o:1},
],
};
const STRUCTURES = {
// ── Nucleobases ──────────────────────────────────────────────────
'Аденин': {
// Purine: C6 has -NH2
atoms: [
...PURINE_RING.atoms,
{id:10,s:'N',x:-100,y:0}, // exo NH2
],
bonds: [
...PURINE_RING.bonds,
{f:4,t:10,o:1},
],
},
'Гуанин': {
// Purine: C6 has =O (keto), C2 has -NH2, N1 has H
atoms: [
...PURINE_RING.atoms,
{id:10,s:'O',x:-100,y:0}, // C6=O
{id:11,s:'N',x:28, y:-95}, // C2-NH2
],
bonds: [
...PURINE_RING.bonds,
{f:4,t:10,o:2}, // C6=O
{f:6,t:11,o:1}, // C2-NH2
],
},
'Цитозин': {
// Pyrimidine: C2=O, C4-NH2
atoms: [
...PYRIM_RING.atoms,
{id:7,s:'O',x:0, y:-95}, // C2=O
{id:8,s:'N',x:88, y:42}, // C4-NH2
],
bonds: [
...PYRIM_RING.bonds,
{f:1,t:7,o:2},
{f:3,t:8,o:1},
],
},
'Тимин': {
// Pyrimidine: C2=O, C4=O, C5-CH3
atoms: [
...PYRIM_RING.atoms,
{id:7,s:'O',x:0, y:-95}, // C2=O
{id:8,s:'O',x:88, y:42}, // C4=O
{id:9,s:'C',x:0, y:95}, // C5-CH3
],
bonds: [
...PYRIM_RING.bonds,
{f:1,t:7,o:2},
{f:3,t:8,o:2},
{f:4,t:9,o:1},
],
},
'Урацил': {
// Pyrimidine: C2=O, C4=O
atoms: [
...PYRIM_RING.atoms,
{id:7,s:'O',x:0, y:-95},
{id:8,s:'O',x:88, y:42},
],
bonds: [
...PYRIM_RING.bonds,
{f:1,t:7,o:2},
{f:3,t:8,o:2},
],
},
// ── Sugars ───────────────────────────────────────────────────────
'Глюкоза': {
// Pyranose ring (Haworth): O at top, C1-C5 clockwise, C6 branch on C5
atoms: [
{id:1,s:'O',x:0, y:-48}, // ring O
{id:2,s:'C',x:46, y:-24}, // C1 (anomeric)
{id:3,s:'C',x:46, y:24}, // C2
{id:4,s:'C',x:0, y:48}, // C3
{id:5,s:'C',x:-46,y:24}, // C4
{id:6,s:'C',x:-46,y:-24}, // C5
{id:7,s:'C',x:-85,y:-52}, // C6 (CH2OH)
{id:8,s:'O',x:85, y:-48}, // C1-OH
],
bonds: [
{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:1},{f:4,t:5,o:1},{f:5,t:6,o:1},{f:6,t:1,o:1},
{f:6,t:7,o:1},{f:2,t:8,o:1},
],
},
'Рибоза': {
// Furanose ring: O at top, C1-C4 clockwise, C5 branch
atoms: [
{id:1,s:'O',x:0, y:-42}, // ring O
{id:2,s:'C',x:40, y:-13}, // C1
{id:3,s:'C',x:25, y:35}, // C2
{id:4,s:'C',x:-25,y:35}, // C3
{id:5,s:'C',x:-40,y:-13}, // C4
{id:6,s:'C',x:-70,y:-48}, // C5 (CH2OH)
{id:7,s:'O',x:72, y:-42}, // C1-OH
],
bonds: [
{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:1},{f:4,t:5,o:1},{f:5,t:1,o:1},
{f:5,t:6,o:1},{f:2,t:7,o:1},
],
},
'Дезоксирибоза': {
// Same as ribose but C2 lacks OH
atoms: [
{id:1,s:'O',x:0, y:-42},
{id:2,s:'C',x:40, y:-13}, // C1
{id:3,s:'C',x:25, y:35}, // C2 (no OH — deoxygenated)
{id:4,s:'C',x:-25,y:35}, // C3
{id:5,s:'C',x:-40,y:-13}, // C4
{id:6,s:'C',x:-70,y:-48}, // C5
{id:7,s:'O',x:72, y:-42}, // C1-OH
],
bonds: [
{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:1},{f:4,t:5,o:1},{f:5,t:1,o:1},
{f:5,t:6,o:1},{f:2,t:7,o:1},
],
},
'Сахароза': {
// Two linked rings (simplified): glucose + fructose, joined via O bridge
atoms: [
// Glucose ring (left)
{id:1, s:'O',x:-60, y:-42}, // ring O
{id:2, s:'C',x:-20, y:-62},
{id:3, s:'C',x:10, y:-28},
{id:4, s:'C',x:-5, y:18},
{id:5, s:'C',x:-55, y:20},
{id:6, s:'C',x:-80, y:-20},
// Bridge O
{id:7, s:'O',x:50, y:-28}, // glycosidic O
// Fructose ring (right, furanose)
{id:8, s:'O',x:82, y:20}, // ring O
{id:9, s:'C',x:115,y:-5},
{id:10,s:'C',x:115,y:40},
{id:11,s:'C',x:75, y:55},
{id:12,s:'C',x:55, y:20},
],
bonds: [
{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:1},{f:4,t:5,o:1},{f:5,t:6,o:1},{f:6,t:1,o:1},
{f:3,t:7,o:1},
{f:7,t:12,o:1},
{f:8,t:9,o:1},{f:9,t:10,o:1},{f:10,t:11,o:1},{f:11,t:12,o:1},{f:12,t:8,o:1},
],
},
// ── Lipids ───────────────────────────────────────────────────────
'Олеиновая кислота': {
// C18 chain with cis double bond at C9-C10; show carboxyl + kink + chain
atoms: [
{id:1,s:'O',x:-85,y:0}, {id:2,s:'C',x:-55,y:0}, {id:3,s:'O',x:-55,y:-42},
{id:4,s:'C',x:-20,y:20}, {id:5,s:'C',x:15, y:0},
{id:6,s:'C',x:50, y:20}, {id:7,s:'C',x:85, y:0},
// cis double bond kink
{id:8,s:'C',x:115,y:10}, {id:9,s:'C',x:150,y:-10},
{id:10,s:'C',x:180,y:10},{id:11,s:'C',x:210,y:-10},
{id:12,s:'C',x:240,y:10},
],
bonds: [
{f:1,t:2,o:2},{f:2,t:3,o:1}, // COOH
{f:2,t:4,o:1},{f:4,t:5,o:1},{f:5,t:6,o:1},{f:6,t:7,o:1},
{f:7,t:8,o:1},{f:8,t:9,o:2}, // C=C double bond
{f:9,t:10,o:1},{f:10,t:11,o:1},{f:11,t:12,o:1},
],
},
'Пальмитиновая кислота': {
// C16 saturated chain; show carboxyl + zigzag
atoms: [
{id:1,s:'O',x:-85,y:0}, {id:2,s:'C',x:-55,y:0}, {id:3,s:'O',x:-55,y:-42},
{id:4,s:'C',x:-20,y:20}, {id:5,s:'C',x:15, y:0},
{id:6,s:'C',x:50, y:20}, {id:7,s:'C',x:85, y:0},
{id:8,s:'C',x:120,y:20}, {id:9,s:'C',x:155,y:0},
{id:10,s:'C',x:190,y:20},{id:11,s:'C',x:225,y:0},
],
bonds: [
{f:1,t:2,o:2},{f:2,t:3,o:1},
{f:2,t:4,o:1},{f:4,t:5,o:1},{f:5,t:6,o:1},{f:6,t:7,o:1},
{f:7,t:8,o:1},{f:8,t:9,o:1},{f:9,t:10,o:1},{f:10,t:11,o:1},
],
},
// ── Nucleotides ──────────────────────────────────────────────────
'АТФ': {
// Adenine + ribose + triphosphate (simplified)
atoms: [
// Adenine ring (purine, scaled down a bit)
{id:1,s:'N',x:42, y:0}, {id:2,s:'C',x:21, y:37}, {id:3,s:'C',x:-21,y:37},
{id:4,s:'C',x:-42,y:0}, {id:5,s:'N',x:-21,y:-37}, {id:6,s:'C',x:21, y:-37},
{id:7,s:'N',x:34, y:77}, {id:8,s:'C',x:0, y:100}, {id:9,s:'N',x:-34,y:77},
{id:10,s:'N',x:-75,y:0}, // NH2
// Ribose
{id:11,s:'O',x:21, y:140},
{id:12,s:'C',x:55, y:155},{id:13,s:'C',x:65,y:195},
{id:14,s:'C',x:25,y:215},{id:15,s:'C',x:-5,y:185},
// Triphosphate tail
{id:16,s:'O',x:-25,y:215},
{id:17,s:'P',x:-60,y:220},{id:18,s:'O',x:-75,y:180},{id:19,s:'O',x:-90,y:250},
{id:20,s:'P',x:-115,y:218},{id:21,s:'O',x:-115,y:175},
{id:22,s:'P',x:-155,y:218},{id:23,s:'O',x:-155,y:175},
],
bonds: [
// purine 6-ring
{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1},
// purine 5-ring
{f:2,t:7,o:1},{f:7,t:8,o:2},{f:8,t:9,o:1},{f:9,t:3,o:2},
// NH2
{f:4,t:10,o:1},
// glycosidic bond N9-ribose
{f:8,t:11,o:1},
// ribose ring
{f:11,t:12,o:1},{f:12,t:13,o:1},{f:13,t:14,o:1},{f:14,t:15,o:1},{f:15,t:11,o:1},
// 5'-O to phosphate
{f:15,t:16,o:1},
// triphosphate chain
{f:16,t:17,o:1},{f:17,t:18,o:2},{f:17,t:19,o:1},
{f:19,t:20,o:1},{f:20,t:21,o:2},
{f:20,t:22,o:1},{f:22,t:23,o:2},
],
},
'АДФ': {
// Adenine + ribose + diphosphate (ATP minus one P)
atoms: [
{id:1,s:'N',x:42, y:0}, {id:2,s:'C',x:21, y:37}, {id:3,s:'C',x:-21,y:37},
{id:4,s:'C',x:-42,y:0}, {id:5,s:'N',x:-21,y:-37}, {id:6,s:'C',x:21, y:-37},
{id:7,s:'N',x:34, y:77}, {id:8,s:'C',x:0, y:100}, {id:9,s:'N',x:-34,y:77},
{id:10,s:'N',x:-75,y:0},
{id:11,s:'O',x:21, y:140},
{id:12,s:'C',x:55, y:155},{id:13,s:'C',x:65,y:195},
{id:14,s:'C',x:25,y:215},{id:15,s:'C',x:-5,y:185},
{id:16,s:'O',x:-25,y:215},
{id:17,s:'P',x:-60,y:220},{id:18,s:'O',x:-75,y:180},{id:19,s:'O',x:-90,y:250},
{id:20,s:'P',x:-115,y:218},{id:21,s:'O',x:-115,y:175},
],
bonds: [
{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1},
{f:2,t:7,o:1},{f:7,t:8,o:2},{f:8,t:9,o:1},{f:9,t:3,o:2},
{f:4,t:10,o:1},
{f:8,t:11,o:1},
{f:11,t:12,o:1},{f:12,t:13,o:1},{f:13,t:14,o:1},{f:14,t:15,o:1},{f:15,t:11,o:1},
{f:15,t:16,o:1},
{f:16,t:17,o:1},{f:17,t:18,o:2},{f:17,t:19,o:1},
{f:19,t:20,o:1},{f:20,t:21,o:2},
],
},
'АМФ': {
// Adenine + ribose + monophosphate
atoms: [
{id:1,s:'N',x:42, y:0}, {id:2,s:'C',x:21, y:37}, {id:3,s:'C',x:-21,y:37},
{id:4,s:'C',x:-42,y:0}, {id:5,s:'N',x:-21,y:-37}, {id:6,s:'C',x:21, y:-37},
{id:7,s:'N',x:34, y:77}, {id:8,s:'C',x:0, y:100}, {id:9,s:'N',x:-34,y:77},
{id:10,s:'N',x:-75,y:0},
{id:11,s:'O',x:21, y:140},
{id:12,s:'C',x:55, y:155},{id:13,s:'C',x:65,y:195},
{id:14,s:'C',x:25,y:215},{id:15,s:'C',x:-5,y:185},
{id:16,s:'O',x:-25,y:215},
{id:17,s:'P',x:-60,y:220},{id:18,s:'O',x:-75,y:180},{id:19,s:'O',x:-90,y:250},
],
bonds: [
{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1},
{f:2,t:7,o:1},{f:7,t:8,o:2},{f:8,t:9,o:1},{f:9,t:3,o:2},
{f:4,t:10,o:1},
{f:8,t:11,o:1},
{f:11,t:12,o:1},{f:12,t:13,o:1},{f:13,t:14,o:1},{f:14,t:15,o:1},{f:15,t:11,o:1},
{f:15,t:16,o:1},
{f:16,t:17,o:1},{f:17,t:18,o:2},{f:17,t:19,o:1},
],
},
// ── Small amino acids ────────────────────────────────────────────
'Серин': {
// HO-CH2-CH(NH2)-COOH → serine
atoms: [
{id:1,s:'O',x:-55,y:0}, {id:2,s:'C',x:-25,y:0}, {id:3,s:'O',x:-25,y:-45}, // COOH
{id:4,s:'C',x:10, y:20}, // Cα (NH2)
{id:5,s:'N',x:10, y:65}, // NH2
{id:6,s:'C',x:45, y:0}, // Cβ (CH2OH)
{id:7,s:'O',x:80, y:20}, // OH
],
bonds: [
{f:1,t:2,o:2},{f:2,t:3,o:1},{f:2,t:4,o:1},
{f:4,t:5,o:1},{f:4,t:6,o:1},{f:6,t:7,o:1},
],
},
'Треонин': {
// CH3-CH(OH)-CH(NH2)-COOH
atoms: [
{id:1,s:'O',x:-55,y:0}, {id:2,s:'C',x:-25,y:0}, {id:3,s:'O',x:-25,y:-45},
{id:4,s:'C',x:10, y:20},
{id:5,s:'N',x:10, y:65},
{id:6,s:'C',x:45, y:0},
{id:7,s:'O',x:80, y:20},
{id:8,s:'C',x:45, y:-45}, // methyl
],
bonds: [
{f:1,t:2,o:2},{f:2,t:3,o:1},{f:2,t:4,o:1},
{f:4,t:5,o:1},{f:4,t:6,o:1},{f:6,t:7,o:1},{f:6,t:8,o:1},
],
},
// ── Other ────────────────────────────────────────────────────────
'Глутатион': {
// γ-Glu-Cys-Gly tripeptide (simplified backbone)
atoms: [
{id:1,s:'N',x:-90,y:0},{id:2,s:'C',x:-55,y:0},{id:3,s:'C',x:-30,y:-35},{id:4,s:'O',x:-30,y:-75},
{id:5,s:'C',x:-20,y:30},{id:6,s:'C',x:10, y:0},{id:7,s:'O',x:10, y:-45},
{id:8,s:'N',x:40, y:20},{id:9,s:'C',x:70, y:0},{id:10,s:'S',x:110,y:20},
{id:11,s:'C',x:70, y:-45},{id:12,s:'O',x:70,y:-90},
],
bonds: [
{f:1,t:2,o:1},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:2,t:5,o:1},{f:5,t:6,o:1},
{f:6,t:7,o:2},{f:6,t:8,o:1},{f:8,t:9,o:1},{f:9,t:10,o:1},{f:9,t:11,o:1},{f:11,t:12,o:2},
],
},
'Кортизол': {
// Steroid (4 fused rings, simplified with 4-ring skeleton)
atoms: [
// Ring A (left 6-ring)
{id:1,s:'C',x:-80,y:10},{id:2,s:'C',x:-55,y:-30},{id:3,s:'C',x:-15,y:-30},
{id:4,s:'C',x:-5, y:10},{id:5,s:'C',x:-30,y:45}, {id:6,s:'C',x:-65,y:45},
// Ring B (middle 6-ring, shares C4-C5 with A)
{id:7,s:'C',x:35, y:-30},{id:8,s:'C',x:55, y:10},{id:9,s:'C',x:30, y:45},
// Ring C (right 6-ring, shares C8-C9 with B)
{id:10,s:'C',x:95, y:-30},{id:11,s:'C',x:105,y:10},{id:12,s:'C',x:80, y:45},
// Ring D (5-ring, right)
{id:13,s:'C',x:130,y:20},{id:14,s:'C',x:125,y:60},
// Functional groups
{id:15,s:'O',x:-15,y:-68},{id:16,s:'O',x:130,y:-15},
],
bonds: [
// Ring A
{f:1,t:2,o:1},{f:2,t:3,o:2},{f:3,t:4,o:1},{f:4,t:5,o:1},{f:5,t:6,o:1},{f:6,t:1,o:1},
// Ring B (C4-C8, C5-C9)
{f:4,t:7,o:1},{f:7,t:10,o:1},{f:10,t:11,o:1},{f:11,t:8,o:1},{f:8,t:9,o:1},{f:9,t:5,o:1},
// Ring C (C8-C11, C9-C12)
{f:8,t:13,o:1},{f:13,t:14,o:1},{f:14,t:12,o:1},{f:12,t:9,o:1},
// Ring D
{f:11,t:13,o:1},
// C3=O and C11-OH
{f:3,t:15,o:2},{f:10,t:16,o:1},
],
},
'Морфин': {
// Complex alkaloid (simplified pentacyclic skeleton)
atoms: [
{id:1,s:'C',x:-30,y:-55},{id:2,s:'C',x:15, y:-55},{id:3,s:'C',x:42, y:-15},
{id:4,s:'C',x:25, y:30}, {id:5,s:'C',x:-20,y:30}, {id:6,s:'C',x:-48,y:-10},
{id:7,s:'O',x:-85,y:-10},{id:8,s:'C',x:65, y:20},{id:9,s:'N',x:50, y:65},
{id:10,s:'C',x:-5, y:75},{id:11,s:'C',x:-42,y:55},{id:12,s:'O',x:95, y:0},
{id:13,s:'C',x:50, y:-45},
],
bonds: [
{f:1,t:2,o:2},{f:2,t:3,o:1},{f:3,t:4,o:2},{f:4,t:5,o:1},{f:5,t:6,o:2},{f:6,t:1,o:1},
{f:6,t:7,o:1},{f:3,t:8,o:1},{f:8,t:9,o:1},{f:9,t:10,o:1},{f:10,t:11,o:1},{f:11,t:5,o:1},
{f:8,t:12,o:1},{f:2,t:13,o:1},{f:13,t:8,o:1},
],
},
};
const emptyMols = getEmpty.all();
let fixed = 0;
for (const mol of emptyMols) {
const s = STRUCTURES[mol.name_ru];
if (!s) continue;
setAtoms.run(JSON.stringify(s.atoms), JSON.stringify(s.bonds), mol.id);
fixed++;
}
console.log(`✓ Phase 8.2: 2D structures added to ${fixed} molecules`);
}
/* ── Fix role_permissions CHECK to include free_student ────────────────── */
try {
const rpNeedsFix = (() => {
try {
db.exec('SAVEPOINT chk_rp_test');
db.exec("INSERT INTO role_permissions (role,permission,enabled) VALUES ('free_student','test',0)");
db.exec('ROLLBACK TO chk_rp_test');
db.exec('RELEASE chk_rp_test');
return false;
} catch {
try { db.exec('ROLLBACK TO chk_rp_test'); db.exec('RELEASE chk_rp_test'); } catch {}
return true;
}
})();
if (rpNeedsFix) {
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`CREATE TABLE role_permissions_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL CHECK (role IN ('teacher', 'student', 'free_student')),
permission TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
UNIQUE (role, permission)
)`);
db.exec('INSERT INTO role_permissions_v2 (id,role,permission,enabled) SELECT id,role,permission,enabled FROM role_permissions');
db.exec('DROP TABLE role_permissions');
db.exec('ALTER TABLE role_permissions_v2 RENAME TO role_permissions');
db.exec('PRAGMA foreign_keys = ON');
console.log('✓ role_permissions CHECK updated to include free_student');
}
} catch (e) {
try { db.exec('PRAGMA foreign_keys = ON'); } catch {}
console.warn('role_permissions migration skipped:', e.message);
}
/* ── Parent links (parent dashboard access) ────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS parent_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
label TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1,
last_used TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_parent_links_student ON parent_links(student_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_parent_links_token ON parent_links(token)');
db.exec(`
CREATE TABLE IF NOT EXISTS parent_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_link_id INTEGER NOT NULL REFERENCES parent_links(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'info',
message TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_parent_notif_link ON parent_notifications(parent_link_id, is_read)');
/* Composite index for parent notification queries ordered by date */
db.exec('CREATE INDEX IF NOT EXISTS idx_parent_notif_link_date ON parent_notifications(parent_link_id, created_at DESC)');
/* Composite index for parent dashboard session queries */
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_user_status_date ON test_sessions(user_id, status, started_at DESC)');
/* ── Admin audit log ───────────────────────────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS admin_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES users(id),
action TEXT NOT NULL,
target TEXT,
detail TEXT,
ip TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_date ON admin_audit_log(created_at DESC)');
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_admin ON admin_audit_log(admin_id)');
/* ── Error log ─────────────────────────────────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS error_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL DEFAULT 'error',
message TEXT NOT NULL,
stack TEXT,
route TEXT,
method TEXT,
user_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_error_log_date ON error_log(created_at DESC)');
/* ── Online Classroom ───────────────────────────────────────────────────── */
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER REFERENCES classes(id) ON DELETE CASCADE,
teacher_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
current_page INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
ended_at TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_cr_sessions_class ON classroom_sessions(class_id, status)');
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
UNIQUE(session_id, user_id)
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE,
page_num INTEGER NOT NULL DEFAULT 1,
bg_color TEXT NOT NULL DEFAULT '#ffffff',
template TEXT NOT NULL DEFAULT 'blank',
UNIQUE(session_id, page_num)
)
`);
// Add template + name columns to existing classroom_pages tables (idempotent)
try { db.exec(`ALTER TABLE classroom_pages ADD COLUMN template TEXT NOT NULL DEFAULT 'blank'`); } catch (_) { /* already exists */ }
try { db.exec(`ALTER TABLE classroom_pages ADD COLUMN name TEXT`); } catch (_) { /* already exists */ }
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_strokes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE,
page_num INTEGER NOT NULL DEFAULT 1,
user_id INTEGER NOT NULL REFERENCES users(id),
tool TEXT NOT NULL DEFAULT 'pencil',
data TEXT NOT NULL,
seq INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_cr_strokes ON classroom_strokes(session_id, page_num, seq)');
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_chat (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_cr_chat ON classroom_chat(session_id)');
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_attendance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
left_at TEXT,
UNIQUE(session_id, user_id)
)
`);
// Add pinned column to classroom_chat if not exists (safe ALTER TABLE)
try { db.exec(`ALTER TABLE classroom_chat ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`); } catch {}
try { db.exec(`ALTER TABLE classroom_chat ADD COLUMN attachment_url TEXT`); } catch {}
try { db.exec(`ALTER TABLE classroom_chat ADD COLUMN attachment_type TEXT`); } catch {}
// Chat reactions
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_chat_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL REFERENCES classroom_chat(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
reaction TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(chat_id, user_id, reaction)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_cr_reactions ON classroom_chat_reactions(chat_id)');
// Per-user session notes
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(session_id, user_id)
)
`);
// Lesson templates (saved whiteboard state)
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
teacher_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
description TEXT DEFAULT '',
pages_data TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Guest token for classroom sessions (public read-only whiteboard access)
try { db.exec("ALTER TABLE classroom_sessions ADD COLUMN guest_token TEXT UNIQUE"); } catch {}
// Board theme per session (synced to all participants)
try { db.exec("ALTER TABLE classroom_sessions ADD COLUMN board_theme TEXT NOT NULL DEFAULT 'chalkboard'"); } catch {}
// Persistent draw permissions (survives server restart)
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_draw_permissions (
session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
PRIMARY KEY (session_id, user_id)
)
`);
// Raised hands (persisted — survives server restart)
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_hands (
session_id INTEGER NOT NULL REFERENCES classroom_sessions(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
PRIMARY KEY (session_id, user_id)
)
`);
// ── Geometry (Planimetry) ────────────────────────────────────────────────────
// Saved geometry constructions (teacher-created tasks/templates)
db.exec(`
CREATE TABLE IF NOT EXISTS geometry_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
teacher_id INTEGER NOT NULL REFERENCES users(id),
class_id INTEGER REFERENCES classes(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT 'Без названия',
description TEXT DEFAULT '',
state_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_tasks_teacher ON geometry_tasks(teacher_id)');
// Student submissions for geometry tasks
db.exec(`
CREATE TABLE IF NOT EXISTS geometry_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES geometry_tasks(id) ON DELETE CASCADE,
student_id INTEGER NOT NULL REFERENCES users(id),
state_json TEXT NOT NULL DEFAULT '{}',
score REAL DEFAULT NULL,
feedback TEXT DEFAULT '',
submitted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(task_id, student_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_task ON geometry_submissions(task_id)');
// Avatar photo (approved URL, NULL = use initials)
try { db.exec("ALTER TABLE users ADD COLUMN avatar_url TEXT DEFAULT NULL"); } catch {}
// Avatar moderation queue
db.exec(`
CREATE TABLE IF NOT EXISTS avatar_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
reviewer_id INTEGER REFERENCES users(id),
reject_msg TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
reviewed_at TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_avatar_req_user ON avatar_requests(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_avatar_req_status ON avatar_requests(status)');
// User preferences (server-synced: whiteboard defaults, dashboard visibility, etc.)
db.exec(`
CREATE TABLE IF NOT EXISTS user_preferences (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Pet columns on users table (ALTER TABLE is idempotent via try/catch)
try { db.exec("ALTER TABLE users ADD COLUMN pet_name TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_color TEXT DEFAULT 'purple'"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_last_petted TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_petting_streak INT DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_last_star TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_bg TEXT DEFAULT 'default'"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_bg_owned TEXT DEFAULT '[]'"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_last_fed TEXT"); } catch {}
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_student ON geometry_submissions(student_id)');