feat(quantik-game): фаза 3 — граф-уровни (движение по f(x)) + зоны

Новый тип уровня: Квантик едет по кривой y=f(x), которую игрок собирает
слайдерами коэффициентов, проходя сквозь зоны-препятствия. Движок
(аддитивно): plot.runner → env-поля curve.runX/runY/runDone (f компилится
1 раз, питает И кривую, И бегунок-героя, без само-ссылки); type zone
(forbidden/target/collect) → булево env-поле zone.hit. Грамматика
выражений ЗАКРЫТА — никаких inzone()-предикатов, только именованные
env-поля (модель t/tries из Ф0), без eval. Глава-созвездие functions из
5 уровней (луч/синус/парабола/модуль/экспонента), разблокировка 9/11/13/
15/17 (цепочка проходима). validateSpec принимает zone+runner. Все 5
уровней независимо проверены на движке (2★ достижимы). npm test 253/8
baseline; custom-sims 26/26; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Maxim Dolgolyov
2026-06-13 17:07:33 +03:00
parent 02ab886bee
commit 978448d99b
9 changed files with 569 additions and 19 deletions
+28
View File
@@ -203,6 +203,34 @@ describe('/api/custom-sims', () => {
assert.ok(txt.includes('&lt;img'), 'escaped form present');
});
it('accepts graph-level spec with zone + runner (Квантик Ф3)', async () => {
const spec = {
specVersion: 1,
meta: { title: 'Граф-уровень' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8 },
params: [{ name: 'a', min: -1, max: 2, step: 0.05, value: 0.5 }],
objects: [
{ id: 'curve', type: 'plot', expr: 'a*x', var: 'x', range: [0, 10], runner: { duration: 5 } },
{ id: 'ball', type: 'point', x: 'curve.runX', y: 'curve.runY', r: 7 },
{ type: 'zone', id: 'pit', kind: 'forbidden', shape: 'rect', x: 5, y: 0, w: 4, h: 2, track: 'ball', label: 'яма' },
{ type: 'zone', id: 'gate', kind: 'target', shape: 'circle', x: 10, y: 5, r: 1, track: 'ball' },
],
goal: { when: 'gate.hit', fail: 'pit.hit', stars: [{ when: 'gate.hit' }] },
};
const res = await inject('POST', '/api/custom-sims', { spec }, teacherToken);
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, teacherToken);
const objs = get.body.sim.spec.objects;
assert.ok(objs.find(o => o.type === 'zone' && o.id === 'pit'), 'zone object preserved');
assert.ok(objs.find(o => o.type === 'plot' && o.runner), 'runner block preserved');
});
it('rejects unknown object type even with zone allowed (400)', async () => {
const bad = { ...VALID_SPEC, objects: [{ type: 'zoney_fake', x: 0, y: 0 }] };
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('owner can DELETE own sim (then 404)', async () => {
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken);
assert.equal(del.status, 200, `got ${del.status}`);