# Phase 5: Авторинг уровней в sim-builder + раздача классу

**Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack

## Objective
Дать учителю собирать **игровые уровни без кода** в существующем sim-builder: задать цель/звёзды/
подсказку/главу/норматив, сохранить как `custom_sims` с `cat='game'`, опубликовать и раздать
классу. Игра начинает грузить уровни из БД (а не только встроенные).

⚠️ ПЕРЕД СТАРТОМ: свериться с base-веткой `feature/sim-builder` — чужой P4-WIP билдера должен
быть смержен. При необходимости влить base в `feature/quantik-game` и разрешить конфликты.

## Tasks
- [x] Task 1: Режим «Игровой уровень» в `sim-builder.js`/`.html`: панель цели (`goal.when`,
      `title`, `hint`, `hold`, `fail`), список звёзд (add/del: `when`+`label`), глава/порядок/`par_ms`.
      Inline-проверка выражений через `SimExpr.compile().error` (как остальные поля билдера).
- [x] Task 2: `buildSpec()` материализует блок `goal`/`game`; `loadFromSim()` раскладывает обратно
      (round-trip), как сделано с plot-range в Ф4 билдера.
- [x] Task 3: Кнопка «Играть» в билдере — открыть текущую спеку в игровом режиме (тест уровня автором).
- [x] Task 4: Каталог уровней: игра грузит `custom_sims` c `cat='game'` (свои+published) — реюз
      `LS.customSimsList`/`Get`. Категория `game` в списке `CATS` (customSimController) + фильтр.
- [x] Task 5: Раздача классу: реюз паттерна Ф6 sim-builder (авто-публикация + `pushNotif` ученикам,
      ссылка `/quantik?level=custom:<id>`); привязка к программе через `lab_sim_links` (`sim_id='custom:<id>'`).
- [x] Task 6: Deep-link `/quantik?level=custom:<id>` (паттерн Ф5/Ф7 sim-builder, доступ own|published|admin).
- [x] Task 7: Тесты: round-trip goal в билдере (headless как Ф4 sim-builder); доступ к чужому
      draft запрещён; published-уровень виден; раздача шлёт уведомление.

## Files to Modify/Create
- `frontend/sim-builder.html`, `frontend/js/sim-builder.js` — режим игрового уровня (аддитивно).
- `backend/src/controllers/customSimController.js` — `CATS` += 'game'; (goal уже в validateSpec из Ф0).
- `frontend/js/game/quantik-game.js` — загрузка уровней из custom_sims + deep-link.
- тест(ы).

## Acceptance Criteria
- Учитель собирает уровень с целью/звёздами, тестирует «Играть», сохраняет/публикует.
- Игра грузит уровни из БД; deep-link открывает конкретный уровень с проверкой доступа.
- Раздача классу публикует + уведомляет; round-trip спеки без потерь; тесты зелёные; lint baseline 0.

## Notes
- Билдер — зона, где мог идти параллельный P4-WIP; правки строго аддитивны, свериться с base.
- Санитизация goal-полей — уже на сервере (Ф0). Клиентская валидация зеркалит её (как в Ф4 билдера).

## Review Checklist
- [x] Все задачи; аддитивность билдера; ownership/доступ корректны; без эмодзи/eval; тесты зелёные

## Handoff to Next Phase

### Как авторённый уровень попадает в реестр игры
- Хранилище: `custom_sims` с `cat='game'`. Спека = обычная SimForge-спека + блок
  `goal{when,title,hint,hold,fail,stars[]}` + блок `game{chapter,order,par_ms,unlockStars?}`.
- `window.QuantikLevels` стал «асинхронным»: встроенные `LEVELS` доступны сразу (offline),
  а опубликованные/свои игровые спеки подмешиваются через **`QuantikLevels.ensureCustom()`**
  (Promise, кэш): `LS.customSimsList()` → фильтр `cat==='game'` → `LS.customSimGet(id)` каждой →
  `customToLevel(row)` → запись реестра. `list()` = `LEVELS.concat(CUSTOM)`; `get(id)` ищет в обоих.
- **Форма записи авторённого уровня** (`customToLevel`): `{ id:'custom:<dbid>', dbid, title,
  chapter:(game.chapter||'custom'), order:(game.order||1000+dbid), unlockStars:(game.unlockStars||0),
  par_ms, subject, hint:(goal.hint), spec, _custom:true }`. Запись БЕЗ `goal` отбрасывается (не уровень).
- Новая глава-созвездие **`custom`** в `CHAPTERS` (levels.js) — авторённые уровни без явной главы
  группируются в неё; map.js рисует автоматически (по метаданным, не тронут). Если автор задал
  `game.chapter='kinematics'` и т.п. — уровень встанет в соответствующее созвездие.

### Deep-link контракт
- `/quantik?level=custom:<dbid>` → `QuantikLevels.getAsync('custom:<dbid>')`: если уже в кэше —
  синхронно; иначе `LS.customSimGet(dbid)` (сервер: доступ own|published|admin → иначе 404/403 → карта).
  Авторённый уровень по deep-link открывается БЕЗ гейта `unlockStars` (получатель ссылки заходит прямо).
  Встроенный `?level=<id>` — как раньше (через `isUnlocked`).
- Прогресс игрока по авторённым уровням пишется так же: `LS.gameProgressSubmit('custom:<dbid>', ...)`
  (`game_progress.level_id` — TEXT ≤120, двоеточие проходит; бэкенд НЕ менялся).

### Share-flow
- Реюз контроллера `customSimController.share` (Ф6). Для `cat==='game'` ссылка/тип уведомления
  переключены: link `/quantik?level=custom:<id>`, тип `game_level_shared` (обычная sim — `/lab?sim=…`,
  `sim_shared`). Авто-публикация + durable `pushNotif` ученикам класса. Ответ теперь содержит `link`.
- Раздача игрового уровня из билдера — той же кнопкой «Раздать» (`openShareModal` → `LS.customSimShare`),
  отдельный UI не нужен. Курикулумная привязка — `lab_sim_links` `sim_id='custom:<id>'` (Ф6, не трогалось).

### Для Phase 6 (лидерборд / живая гонка)
- Лидерборд может агрегировать `game_progress` по `level_id` (включая `custom:<dbid>`). Уровень-метаданные
  (title/chapter) для custom доступны через `QuantikLevels.getAsync` или прямой `LS.customSimGet`.
- Живая гонка (мост `sim_state`) — он на base-ветке sim-builder Ф7; авторённый игровой уровень уже
  монтируется тем же `SimEngine`, что и встроенные, поэтому мост применим без изменений в этой фазе.
- Авторинг-панель пишет `goal`/`game` только при `st.game.enabled` — обычные симуляции не затронуты.
