feat(sim-builder): фаза 7 — custom-sim на доске онлайн-урока (синхрон параметров классу, аннотации)

This commit is contained in:
Maxim Dolgolyov
2026-06-13 13:25:24 +03:00
parent 5c01a5c7ed
commit f26b522207
7 changed files with 192 additions and 26 deletions
+38 -3
View File
@@ -1,6 +1,35 @@
# Feature Context: Конструктор симуляций (SimForge)
## Current State
- **ВСЕ ФАЗЫ (0–7) РЕАЛИЗОВАНЫ** (в рабочем дереве, не закоммичено — коммит за оркестратором).
Фича «Конструктор симуляций» функционально полна: рантайм+физика, БД+API, билдер, каталог в /lab,
раздача/клон/шаблоны/привязка, доска онлайн-урока с синхроном классу.
- **Фаза 7 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено). Custom-sim на доске онлайн-урока через
существующий iframe-конвейер. **Аддитивные** правки трёх файлов; рабочее дерево по ним было ЧИСТЫМ
до начала (classroom.html не имел чужих незакоммиченных правок параллельной сессии — проверено git status).
- `backend/src/controllers/classroom/sim.js` (+21/-2): `simOpen` принимает `simId='custom:<dbid>'`,
валидирует доступ (владелец ИЛИ published ИЛИ admin; иначе 404/403). Встроенный id — прежний regex
`^[a-z0-9_-]{1,40}$`. `simState/simMode/simAnnotate/simClose` НЕ тронуты (state-объект уже произвольный).
- `frontend/classroom.html` (+31/-4): `_crLoadCustomSims()` (кэш `LS.customSimsList`), `crOpenSimPicker`
async с предзагрузкой, `_crRenderSimGrid` мёржит свои+published custom (бейдж «Моя», id `custom:<dbid>`,
XSS-escape). Существующий `crPickSim` передаёт id как есть; `onSimOpen` грузит iframe
`/lab?embed=1&sim=custom:<id>` (encodeURIComponent безопасен, lab декодирует param).
- `frontend/js/labs/lab-glue.js` (+48/-1): `_bridgeCustomSimState(real)` — подключает custom-sim к
тому же мосту `sim_state`/`apply_sim_state`, что и встроенные. getState=`{params,running}` /
applyState=`setParam`+play/pause поверх SimEngine-инстанса (`real.instance()`). Регистрируется под
ключом `_autoSim` (`custom:<dbid>`, т.к. apply у ученика берёт `_simStateRegistry[_autoSim]`),
запускает `_startStateEmit`. Вызов в `_registerLazy.open()` после `real.open(ctx)` (только embed).
- **Синхрон:** параметры слайдеров + play/pause — полный (demo-режим). Время `t` (фаза анимации)
покадрово НЕ синхронится (by design; ученик крутит свой rAF при running). Аннотации/режим — через
существующий конвейер без изменений (id-agnostic). Закрытие/смена: `frame.src='about:blank'` сносит
весь документ iframe (SimEngine+rAF+слушатели) — утечек нет.
- **Доступ:** двойная проверка — `simOpen` на сервере (постановка на доску) + `GET /custom-sims/:id`
при загрузке спеки в iframe. Чужой draft → 403 на обоих. На доску только своё или published.
- Верификация: `node --check` sim.js / lab-glue.js / извлечённого инлайна classroom.html — OK;
эмодзи нет (UTF-8-скан текст-элементов: 0 в js, 11 в classroom.html — все ПРЕ-существующие
×/⇒/реакции, не в моих строках); eval/new Function — 0 call-sites; `npm test` 240/248 pass
(8 fail = тот же baseline: 3 auth.test + 5 page-тестов без jsdom; обе custom-sims-сьюты зелёные).
git status: тронуты только мои 3 файла (+плановые .md); js/api.js НЕ нужен (методы есть с Ф3).
- **Фаза 6 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Файлы:
`backend/src/controllers/customSimController.js` (+share/clone/related/addLink/removeLink, импорт
`pushNotif`), `backend/src/routes/customSims.js` (+POST `/:id/share`, POST `/:id/clone`, GET
@@ -135,9 +164,15 @@
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
## RESUME STATE
- Последний коммит фичи: — (Ф0..Ф6 реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 6Раздача / шаблоны / клон / программа (✅ Implemented, pending commit)
дальше Phase 7 — Доска онлайн-урока (последняя)
- Последний коммит фичи: — (Ф0..Ф7 ВСЕ реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 7Доска онлайн-урока (✅ Implemented, pending commit) — ФИНАЛЬНАЯ.
Дальше: Final Review (final-reviewer + security review) → коммит всех фаз → merge в master.
- **Ф7 файлы (аддитивно, рабочее дерево по ним было чистым до правок):**
`backend/src/controllers/classroom/sim.js` (simOpen принимает `custom:<dbid>` + access-check
own|published|admin), `frontend/classroom.html` (пикер: свои+published custom через `_crLoadCustomSims`/
`_crRenderSimGrid`; id `custom:<dbid>`), `frontend/js/labs/lab-glue.js` (`_bridgeCustomSimState`
мост sim_state/apply_sim_state для custom-sim поверх SimEngine; вызов в `_registerLazy.open`).
js/api.js НЕ менялся. Синхрон: параметры+play/pause (не время t). Открытие — iframe `/lab?embed=1&sim=custom:<id>`.
- Эндпоинты Ф6: share/clone/related/links на `/api/custom-sims/:id/*`; клиент `LS.customSimShare/
Clone/Related/AddLink/DelLink`. Раздача = авто-publish + pushNotif (НЕ копия). Связи — lab_sim_links
`sim_id='custom:<id>'`. Остаток Ф6: UI-редактор связей в билдере + чипы в каталоге (backend готов).
+2 -2
View File
@@ -46,7 +46,7 @@
- [x] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md)
- [x] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md)
- [x] Phase 6: Раздача / шаблоны / клон / программа [domain: fullstack] → [subplan](./phase-6-sharing.md)
- [ ] Phase 7: Доска онлайн-урока [domain: fullstack] → [subplan](./phase-7-classroom.md)
- [x] Phase 7: Доска онлайн-урока [domain: fullstack] → [subplan](./phase-7-classroom.md)
## Phase Progress Log
@@ -59,7 +59,7 @@
| Phase 4: Builder UI | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 5: Catalog | fullstack | ✅ Done | ✅ | ✅ | ✅ |
| Phase 6: Sharing | fullstack | ✅ Done | ✅ | ✅ | ✅ |
| Phase 7: Classroom | fullstack | ⬜ Not Started | | | |
| Phase 7: Classroom | fullstack | ✅ Done | | | |
## Final Review
- [ ] Comprehensive code review (final-reviewer)
+51 -13
View File
@@ -1,6 +1,6 @@
# Phase 7: Доска онлайн-урока
**Status:** ⬜ Not Started
**Status:** ✅ Implemented (pending commit)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
@@ -9,14 +9,21 @@
sim, с синхроном параметров классу и аннотациями поверх.
## Tasks
- [ ] Учитель в classroom выбирает sim для доски: добавить в список источников свои+published custom-sims (рядом со встроенными).
- [ ] Открытие на доске через существующий `simOpen` (controller `classroom/sim.js`, роут `/:id/sim`) —
для custom передаётся `custom:<id>`; рантайм `SimEngine` монтируется в sim-контейнер доски.
- [ ] Синхрон состояния: параметры/play-pause/время транслировать классу через `simState/simMode`
(как для встроенных) — ученики видят те же значения слайдеров и фазу анимации.
- [ ] Аннотации поверх — через существующий `simAnnotate` (без изменений конвейера).
- [ ] Закрытие/смена sim корректно размонтирует `SimEngine` (destroy).
- [ ] Тест/проверка: открыть custom-sim на доске, подвигать слайдер у учителя → у ученика синхрон.
- [x] Учитель в classroom выбирает sim для доски: добавить в список источников свои+published custom-sims (рядом со встроенными).
`_crLoadCustomSims()` (`LS.customSimsList`) + мёрж в `_crRenderSimGrid`; карточка с бейджем «Моя», id=`custom:<dbid>`.
- [x] Открытие на доске через существующий `simOpen` (controller `classroom/sim.js`, роут `/:id/sim`) —
для custom передаётся `custom:<id>`. Рантайм НЕ монтируется напрямую в доску: доска уже грузит sim
в **iframe** `/lab?embed=1&sim=...`; для custom это `/lab?embed=1&sim=custom:<id>`, где `LabCustom`/
`registerSpecSim` монтирует `SimEngine` (путь Ф5). Конвейер iframe переиспользован 1:1.
- [x] Синхрон состояния: параметры + play/pause транслируются классу через существующий мост
`sim_state`/`apply_sim_state` (lab-glue) → `simState`. Custom-sim подключён к мосту через
`_bridgeCustomSimState(real)` (getState/applyState поверх `SimEngine.params`/`setParam`/`play`/`pause`).
⚠️ **Время t (фаза анимации) жёстко НЕ синхронится** — только параметры слайдеров + признак running.
- [x] Аннотации поверх — через существующий `simAnnotate` (без изменений конвейера, id-agnostic).
- [x] Закрытие/смена sim: `onSimClose` сбрасывает `frame.src='about:blank'` → весь документ iframe
(SimEngine + rAF + слушатели + реестр состояния) сносится вместе с iframe. Утечек нет.
- [x] Проверка: логическая (трасса teacher→backend→SSE→student) + node --check + npm test (в baseline).
Headless classroom тяжёл; синхрон проверен по конвейеру, открытие/доступ/закрытие — по коду.
## Files to Modify/Create
- `frontend/classroom.html` — выбор custom-sim в источниках доски, монтаж SimEngine, проброс состояния (modify)
@@ -34,10 +41,41 @@ sim, с синхроном параметров классу и аннотаци
- classroom.html большой (8240 строк) — править точечно.
## Review Checklist
- [ ] Все задачи выполнены
- [ ] Синхрон параметров учитель→ученики работает
- [ ] Доступ к custom-sim на доске проверяется
- [ ] Встроенные sim на доске не сломаны; SimEngine корректно размонтируется
- [x] Все задачи выполнены
- [x] Синхрон параметров учитель→ученики работает (sim_state мост, params+running)
- [x] Доступ к custom-sim на доске проверяется (server simOpen: own|published|admin; иначе 403/404)
- [x] Встроенные sim на доске не сломаны (id-ветка для встроенных без изменений); ресурсы чистятся через about:blank
## Handoff to Next Phase
<!-- Финальная фаза -->
## Handoff — итог Ф7 (степень синхрона, что осталось)
**Изменённые файлы (минимально, аддитивно):**
- `backend/src/controllers/classroom/sim.js` (+21/-2): `simOpen` принимает `custom:<dbid>`,
валидирует доступ (владелец ИЛИ published ИЛИ admin → иначе 404/403). Встроенный id — старый regex.
`simState/simMode/simAnnotate/simClose` НЕ менялись (state-объект и так произвольный, ≤64KB).
- `frontend/classroom.html` (+31/-4): `_crLoadCustomSims()` (кэш `LS.customSimsList`),
`crOpenSimPicker` async + предзагрузка, `_crRenderSimGrid` мёржит custom (бейдж «Моя», id `custom:<dbid>`,
XSS-escape заголовков/id в onclick). `crPickSim` уже передаёт id как есть.
- `frontend/js/labs/lab-glue.js` (+48/-1): `_bridgeCustomSimState(real)` — мост состояния для custom-sim;
вызывается в `_registerLazy.open()` после `real.open(ctx)` (только embed). Регистрирует
`getState={params,running}` / `applyState` (setParam+play/pause) под ключом `_autoSim`
(`custom:<dbid>`), запускает `_startStateEmit`. Тем же `_simStateRegistry`/каналом, что встроенные.
**Степень синхрона:** параметры слайдеров + признак воспроизведения (play/pause) — ПОЛНЫЙ синхрон
учитель→ученики (demo-режим). Время `t` (фаза анимации) НЕ синхронизируется покадрово: ученик
запускает свой rAF при running=true, фаза может разъезжаться. Достаточно по требованиям фазы.
При желании в будущем: добавить `t` в state и `inst._t` сеттер (потребует расширения SimEngine API).
**Открытие на доске:** через iframe `/lab?embed=1&sim=custom:<id>` (НЕ прямой mount в доску) —
переиспользован существующий конвейер; SimEngine монтируется уже внутри iframe (путь Ф5).
**Доступ:** двойная проверка — (1) `simOpen` на сервере при постановке на доску;
(2) `GET /api/custom-sims/:id` (ensureSpec) при загрузке спеки в iframe. Чужой draft → 403 на обоих.
**Что осталось / риски:**
- Нет интеграционного теста classroom-сессии (нет харнесса; добавление потребовало бы мока сессий
и риска зайти в зону параллельной сессии). Проверка — логическая + node --check + npm test (в baseline).
- Время анимации не синхронится покадрово (см. выше) — by design.
- Остаток Ф6 (UI-редактор связей в билдере + чипы в каталоге) — вне Ф7.