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,6 +28,7 @@ const MAX_POINTS = 1000; // точек в polyline/path/points
const OBJECT_TYPES = new Set([
'point', 'segment', 'vector', 'circle', 'rect',
'polyline', 'path', 'label', 'plot', 'readout',
'zone', // Квантик Ф3: зона-препятствие/цель/сбор (граф-уровни)
]);
const STATUSES = new Set(['draft', 'published']);
@@ -189,6 +190,14 @@ function validateSpec(spec) {
out.drag.param = sanitizeText(o.drag.param, 60);
if (o.drag.paramY !== undefined) out.drag.paramY = sanitizeText(o.drag.paramY, 60);
}
// zone{} — track = id отслеживаемой точки (Квантик Ф3): санитизируем как id.
if (type === 'zone' && o.track !== undefined) out.track = sanitizeText(o.track, 60);
// runner{} на plot (Квантик Ф3): duration — число/выражение (длина).
if (o.runner && typeof o.runner === 'object' && !Array.isArray(o.runner)) {
if (o.runner.duration !== undefined) checkExpr(o.runner.duration, `objects[${i}].runner.duration`, errs);
}
return out;
});
+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}`);