Files
feuerwerk/tools/e2e/53-curves.mjs
T
adminandClaude Opus 5.5 e7a360db73
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Successful in 15m36s
Offer the easings of an animation key on Lerp and sound FX ramps
A timed Set sound FX parameters ran only linearly, and a Lerp had four
quadratic eases of its own. An animation key already had the full set.
Now all three offer the same choice: Linear, Step, the ten curves from
Sine to Bounce with In, Out or In-out, and a drawn Curve asset.

- easingParams in src/core/graph/nodes/easing.ts gives a node the
  fields Easing, Direction and Curve from the key's own lists, and
  easingOfParams reads them back as a key interpolation.
- There is no migration. A Lerp saved with the old in, out or inOut
  runs linear until its easing is picked again.
- Set sound FX parameters hands the easing to the player. Linear and
  Step stay ramps of the audio clock, every other easing goes to
  setValueCurveAtTime as 200 samples a second, in semitones and dB.
- Step now reaches its end at t 1. A key never asked for that, it
  reads the next key itself, so animations play as before.
- The curve-in-use count and the warning for a gone curve cover the
  sound node too.

Tests:
- node-catalog: Lerp with cubic in, quad out and in-out, a curve
  without a direction, step, an unknown easing as linear. The sound
  node hands on a curve, step and a gone curve as linear.
- sound-library: an eased change becomes a curve in semitones and
  decibels, a step a jump at the end.
- node-catalog and curve after dropping the old values: 399, green.
- e2e 6, 34, 53 on melektaus-atx: ALL PASSED, 198 checks. In Chrome a
  2 s Quad in from depth 7 to -5 read speed 0.864 early and 0.5 at the
  end, a 1 s Step held 1 and then jumped to 0.5.
- Counter-check: with curves sent as linear ramps, the curve test of
  the sound library fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:05:00 +02:00

537 lines
22 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.
import { edge, node } from '../lib/builders.mjs';
/**
* Curve assets (#247): made from the + menu and opened by its row, bent in the Curve tab with the
* real mouse – a point put down on free space by a double-click, turned smooth through the header, a
* handle pulled, a point taken away by Delete and by its menu –
* and then picked where a built-in ease is picked, on an animation key and on a `Lerp` node. The
* values those two produce are compared with the curve worked out here, by walking its Bezier
* segments, not with the editor's own solver.
*
* Everything it adds carries a `cv-` id or is the curve it made, and goes again at the end.
*/
/** The curve's value at `t`, from its segments walked finely and read off in between. */
function expectedAt(points, t) {
for (let i = 1; i < points.length; i++) {
const from = points[i - 1];
const to = points[i];
if (t > to.x) continue;
const c1 = { x: from.x + (from.out?.x ?? 0), y: from.y + (from.out?.y ?? 0) };
const c2 = { x: to.x + (to.in?.x ?? 0), y: to.y + (to.in?.y ?? 0) };
const at = (s, a, b, c, d) =>
(1 - s) ** 3 * a + 3 * (1 - s) ** 2 * s * b + 3 * (1 - s) * s * s * c + s ** 3 * d;
let previous = { x: from.x, y: from.y };
for (let step = 1; step <= 20000; step++) {
const s = step / 20000;
const here = { x: at(s, from.x, c1.x, c2.x, to.x), y: at(s, from.y, c1.y, c2.y, to.y) };
if (here.x >= t) {
const share = here.x > previous.x ? (t - previous.x) / (here.x - previous.x) : 1;
return previous.y + (here.y - previous.y) * share;
}
previous = here;
}
return to.y;
}
return points.at(-1).y;
}
export async function curves(t) {
const { check, json, evaluate, mutate, click, doubleClick, drag, settle, sleep, shot, showTab, undo } = t;
const inOverlay = async (text) => {
const box = await json('__gm.overlayRect()');
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
return (
hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null
);
};
const menuRow = async (label) => {
const rows = (await json('__gm.menuRows()')) ?? [];
const row = rows.find((r) => typeof r === 'string' && (r === label || r.endsWith(` ${label}`)));
return row ? inOverlay(row) : null;
};
const pointsOf = (id) => json(`__gm.curve.points(${JSON.stringify(id)})`);
// 53a. The + menu makes one; its row opens the Curve tab on it, a straight line of two points.
await showTab('scene');
await t.clickText('+');
const offered = (await json('__gm.menuRows()')) ?? [];
const entry = await inOverlay('Curve');
check('the + menu offers Curve', offered.includes('Curve') && Boolean(entry), JSON.stringify(offered));
if (!entry) return;
await click(entry.x, entry.y);
await settle();
const curveId = (
await json("__gm.state.project.assets.filter((a) => a.type === 'curve').map((a) => a.id)")
).at(-1);
// "Curve" is the new asset's name, the Easing value and the row label at once – a name of its own
// keeps `inspectorField('Curve')` on the row.
await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name = 'Swoop 53'`,
);
const row = await json(`__gm.assetRow(${JSON.stringify(curveId)})`);
await doubleClick(row.x, row.y);
await settle();
const fresh = await pointsOf(curveId);
check(
'a double-click on the new curve opens the Curve tab on a straight line from (0, 0) to (1, 1)',
(await json('__gm.curve.current()')) === curveId &&
JSON.stringify(fresh) ===
JSON.stringify([
{ x: 0, y: 0 },
{ x: 1, y: 1 },
]) &&
(await json('__gm.curve.info()')) === '2 points',
JSON.stringify({ current: await json('__gm.curve.current()'), fresh }),
);
// 53b. A double-click on free space puts a point there, and it is the picked one (#288); a click
// alone adds nothing.
const spot = await json('__gm.curve.at(0.5, 0.9)');
// Past the double-click that opened the tab, and again past the click that must not count.
await sleep(500);
await click(spot.x, spot.y);
const onlyClicked = (await pointsOf(curveId))?.length;
await sleep(500);
await doubleClick(spot.x, spot.y);
await settle();
const put = await pointsOf(curveId);
const middle = put?.[1];
check(
'a double-click on free space puts a point there, between the ends, and picks it – a click adds none',
onlyClicked === 2 &&
put?.length === 3 &&
Math.abs(middle.x - 0.5) < 0.01 &&
Math.abs(middle.y - 0.9) < 0.01 &&
!middle.in &&
!middle.out &&
(await json('__gm.curve.picked()')) === 1,
JSON.stringify({ put, picked: await json('__gm.curve.picked()') }),
);
// 53c. Point: Smooth grows its handles, and pulling the right one up bends the curve – the left
// handle turns with it, the other way round.
const pointButton = await json('__gm.curve.pointButton()');
await click(pointButton.x, pointButton.y);
await sleep(100);
const smooth = await menuRow('Smooth');
if (smooth) await click(smooth.x, smooth.y);
await settle();
const rounded = await pointsOf(curveId);
const grown = rounded?.[1];
check(
'Point: Smooth grows both handles of the picked point, in line',
grown?.mode === 'smooth' &&
Boolean(grown.in && grown.out) &&
Math.abs(grown.in.x * grown.out.y - grown.in.y * grown.out.x) < 1e-9,
JSON.stringify({ grown, button: pointButton }),
);
if (!grown?.out) return;
// The handle reaches above what the straight line was fitted to; Fit frames it again.
const fit = await json('__gm.curve.fitAt()');
await click(fit.x, fit.y);
await settle();
const tip = await json(`__gm.curve.at(${grown.x + grown.out.x}, ${grown.y + grown.out.y})`);
await drag(tip.x, tip.y, tip.x, tip.y - 60, { steps: 8 });
await settle();
const bent = await pointsOf(curveId);
const pulled = bent?.[1];
const across = pulled && pulled.in.x * pulled.out.y - pulled.in.y * pulled.out.x;
check(
'dragging the right handle up raises it, and the smooth point turns its left handle to match',
pulled?.out.y > grown.out.y + 0.1 && Math.abs(across) < 1e-6 && pulled.in.x < 0 && pulled.in.y < 0,
JSON.stringify({ before: grown, after: pulled }),
);
await shot('53-curve-tab');
// The preview stands still until its button runs it; pressed again, it holds.
// Three samples over 0.6 s: longer than the half second the dot rests at the end of each pass.
const sample = async () => {
const seen = [];
for (let i = 0; i < 3; i++) {
if (i > 0) await sleep(300);
seen.push((await json('__gm.curve.preview()'))?.t);
}
return seen;
};
const previewButton = await json('__gm.curve.previewButton()');
const idle = await sample();
await click(previewButton.x, previewButton.y);
await settle();
const runningButton = await json('__gm.curve.previewButton()');
const running = await sample();
await click(previewButton.x, previewButton.y);
await settle();
const held = [(await json('__gm.curve.preview()'))?.t];
await sleep(250);
held.push((await json('__gm.curve.preview()'))?.t);
check(
'the preview stands still at first, the Preview button runs it, and pressed again holds it',
!previewButton.toggled &&
new Set(idle).size === 1 &&
runningButton.toggled &&
new Set(running).size > 1 &&
!(await json('__gm.curve.previewButton()')).toggled &&
held[0] === held[1],
JSON.stringify({ previewButton, idle, running, held }),
);
// The preview's dot sits on the line at its t, and its twin beside the graph at the same height.
await evaluate('__gm.curve.setPreviewTime(0.35)');
const preview = await json('__gm.curve.preview()');
const onLine = await json(`__gm.curve.at(${preview?.t}, ${expectedAt(bent, preview?.t ?? 0)})`);
check(
'the preview dot runs along the drawn curve, and its twin beside the graph at the same height',
Math.abs(preview?.t - 0.35) < 1e-9 &&
Math.abs(preview.v - expectedAt(bent, 0.35)) < 0.002 &&
Math.abs(preview.dot.x - onLine.x) < 1 &&
Math.abs(preview.dot.y - onLine.y) < 1 &&
Math.abs(preview.bar.y - preview.dot.y) < 1e-6 &&
preview.bar.x > preview.dot.x,
JSON.stringify({ preview, onLine }),
);
// 53d. An animation key picks the curve in the inspector, and the preview draws the value it gives.
await mutate(`
const p = __gm.state.project;
const sprite = p.assets.find((a) => a.type === 'sprite');
p.assets.push({ id: 'cv-anim', type: 'animation', name: 'Curve slide', props: { fps: 10, playback: 'once' },
root: sprite.id,
tracks: [{ id: 'cv-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 10, value: 100 }] }] });`);
await evaluate("__gm.openDocument('asset:cv-anim')");
await settle();
const cell = await json("__gm.animation.cellAt('cv-row', 0)");
await click(cell.x, cell.y);
await settle();
const easing = await json("__gm.inspectorField('Easing')");
if (easing) {
await click(easing.x, easing.y);
await sleep(100);
const curveEntry = await menuRow('Curve');
if (curveEntry) await click(curveEntry.x, curveEntry.y);
await settle();
}
const key = async () =>
(await json("__gm.state.project.assets.find((a) => a.id === 'cv-anim').tracks[0].keys[0]"))
?.interpolation;
const curveRow = await json("__gm.inspectorField('Curve', true)");
check(
'Easing: Curve on the key takes the one curve there is and shows it in a Curve row',
(await key()) === `curve:${curveId}` && Boolean(curveRow),
JSON.stringify({ interpolation: await key(), curveRow, easing }),
);
const rootId = await json("__gm.animation.preview.objectId('')");
const drawnAt = async (frame) => {
await evaluate(`__gm.animation.scrub(${frame})`);
await sleep(100);
return (await json(`__gm.animation.preview.item(${JSON.stringify(rootId)})`))?.x;
};
const drawn = [await drawnAt(3), await drawnAt(5), await drawnAt(8)];
const wanted = [0.3, 0.5, 0.8].map((share) => 100 * expectedAt(bent, share));
check(
'the preview puts the sprite where the curve says, not where a straight line would',
drawn.every((x, i) => Math.abs(x - wanted[i]) < 0.05) && Math.abs(drawn[1] - 50) > 5,
JSON.stringify({ drawn, wanted }),
);
await shot('53-curve-key');
// 53e. A Lerp picks it too – Easing: Curve, then the curve in its row – and the game logs its value.
await mutate(`
const p = __gm.state.project;
p.assets.push({ id: 'cv-lerpy', type: 'behavior', name: 'Curve lerp', props: { enabled: true, runInEditor: false },
variables: [], graph: ${JSON.stringify({
nodes: [
node('cv-start', 'OnStart', 0, 0),
node('cv-lerp', 'Lerp', 0, 160, { a: 0, b: 100, t: 0.3 }),
node('cv-log', 'Log', 300, 0),
],
edges: [
edge('cv-e1', 'cv-start', 'exec', 'cv-log', 'exec'),
edge('cv-e2', 'cv-lerp', 'result', 'cv-log', 'message'),
],
})} });
const scene = __gm.state.activeScene;
const holder = structuredClone(scene.objects[0]);
Object.assign(holder, { id: 'cv-holder', assetId: 'cv-lerpy', parentId: '', name: 'Curve holder',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete holder.groupId;
scene.objects.push(holder);
__gm.state.select({ kind: 'node', assetId: 'cv-lerpy', nodeId: 'cv-lerp', nodeIds: ['cv-lerp'], annotationIds: [] });`);
await showTab('graph');
await settle();
const pick = async (field, label) => {
const at = await json(`__gm.inspectorField(${JSON.stringify(field)}, true)`);
if (!at) return null;
await click(at.x, at.y);
await sleep(100);
const option = await menuRow(label);
if (option) await click(option.x, option.y);
else await t.press('Escape');
await settle();
return option;
};
await pick('Easing', 'Curve');
const unpicked = await json("__gm.graph.nodeSettings('cv-lerp')");
const name = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name`);
const picked = await pick('Curve', `${name} (curve)`);
const params = await json(
"__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params",
);
const settings = await json("__gm.graph.nodeSettings('cv-lerp')");
check(
'Easing: Curve brings the Curve row onto the node, and picking the curve there sets it',
params?.ease === 'curve' &&
params.curve === curveId &&
Boolean(picked) &&
unpicked?.length === 2 &&
settings?.some((s) => s.includes(name)),
JSON.stringify({ params, unpicked, settings, picked }),
);
await shot('53-curve-lerp');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await t.stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Curve holder]'))
.map((line) => Number(line.split(']').pop()));
const lerpWanted = 100 * expectedAt(bent, 0.3);
check(
'the running Lerp gives what the curve says at t 0.3',
logged.length === 1 && Math.abs(logged[0] - lerpWanted) < 0.05 && Math.abs(logged[0] - 30) > 1,
JSON.stringify({ logged, lerpWanted }),
);
// 53f. Undo takes the pick back to the plain Lerp; then everything made here goes.
await showTab('graph');
await undo();
await settle();
const undone = await json(
"__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params",
);
check('undo takes the picked curve back off the Lerp', !undone?.curve, JSON.stringify(undone));
// 53g. A preset starts the curve over from a built-in ease, and the key follows it at once.
await evaluate(`__gm.openDocument('asset:${curveId}')`);
await settle();
const presetButton = await json('__gm.curve.presetButton()');
await click(presetButton.x, presetButton.y);
await sleep(100);
const presets = (await json('__gm.menuRows()')) ?? [];
// Thirty-one entries run past the bottom of the popup; its search box is the way to the last ones.
await t.typeText('bounce out');
await t.press('Enter');
await settle();
const bounced = await pointsOf(curveId);
await evaluate("__gm.openDocument('asset:cv-anim')");
await settle();
const bouncedAt = await drawnAt(5);
check(
'Preset → Bounce out replaces the curve with the traced ease, and the key follows it (76.6 at the middle)',
presets.filter((r) => typeof r === 'string').length === 31 &&
bounced?.length > 8 &&
Math.abs(bouncedAt - 76.5625) < 0.4,
JSON.stringify({ presets: presets.length, points: bounced?.length, bouncedAt }),
);
await shot('53-curve-preset');
await headerAndScrub(t, curveId, bounced);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('cv-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('cv-') && a.id !== ${JSON.stringify(curveId)});
__gm.state.select(null)`);
await showTab('scene');
}
/**
* 53h–53k on the Bounce out curve the preset left: the picked point's `t` and `v` fields, the plain
* arrow over points, the magnet, and the preview dragged by its knob and by its dot beside the graph.
*/
async function headerAndScrub(t, curveId, bounced) {
const { check, json, evaluate, click, doubleClick, drag, press, settle, sleep, shot, undo } = t;
const pointsOf = () => json(`__gm.curve.points(${JSON.stringify(curveId)})`);
const at = (tt, v) => json(`__gm.curve.at(${tt}, ${v})`);
const onGrid = (n) => Math.abs(n / 0.05 - Math.round(n / 0.05)) < 1e-6;
await evaluate(`__gm.openDocument('asset:${curveId}')`);
await settle();
const fit = await json('__gm.curve.fitAt()');
await click(fit.x, fit.y);
await settle();
// Somewhere left of t 0: no point goes there, so a click only drops the pick.
const aside = await at(-0.08, 0.5);
// 53h. A picked point shows its t and v in the header; typed in, it goes there, one undo step each.
const target = bounced[2];
const spot = await at(target.x, target.y);
await click(spot.x, spot.y);
await settle();
const shownT = await json("__gm.curve.placeField('t')");
const shownV = await json("__gm.curve.placeField('v')");
const typeInto = async (axis, text) => {
const field = await json(`__gm.curve.placeField(${JSON.stringify(axis)})`);
await click(field.x, field.y);
await t.typeText(text);
await t.press('Enter');
await settle();
};
const middle = ((bounced[1].x + bounced[3].x) / 2).toFixed(3);
await typeInto('t', middle);
await typeInto('v', '0.42');
const typed = await pointsOf();
await click(aside.x, aside.y);
await undo();
const oneBack = await pointsOf();
await undo();
const twoBack = await pointsOf();
check(
'the t and v fields show the picked point, move it when typed into, and each is one undo step',
(await json('__gm.curve.picked()')) === -1 &&
shownT.enabled &&
Math.abs(Number(shownT.text) - target.x) < 0.001 &&
Math.abs(Number(shownV.text) - target.y) < 0.001 &&
typed?.length === bounced.length &&
typed[2].x === Number(middle) &&
typed[2].y === 0.42 &&
oneBack[2].x === Number(middle) &&
oneBack[2].y === target.y &&
JSON.stringify(twoBack) === JSON.stringify(bounced),
JSON.stringify({
shownT,
shownV,
middle,
typed: typed?.[2],
oneBack: oneBack?.[2],
twoBack: twoBack?.[2],
}),
);
// 53i. An end keeps its t: the field is off, its v is not. Over a point the arrow stays plain.
const start = await at(0, bounced[0].y);
await click(start.x, start.y);
await settle();
const endT = await json("__gm.curve.placeField('t')");
const endV = await json("__gm.curve.placeField('v')");
await t.mouse('mouseMoved', start.x, start.y);
await settle();
const overPoint = await json('__gm.curve.cursor()');
const free = await at(0.5, 0.2);
await t.mouse('mouseMoved', free.x, free.y);
await settle();
const overSpace = await json('__gm.curve.cursor()');
check(
"an end's t field is off and its v field on; over a point the cursor stays the plain arrow",
!endT.enabled && endV.enabled && overPoint === 'default' && overSpace === 'crosshair',
JSON.stringify({ endT, endV, overPoint, overSpace }),
);
// 53j. With the magnet on, a point put down and then dragged lands on the 0.05 grid in t and v.
const magnet = await json('__gm.curve.snapButton()');
await click(magnet.x, magnet.y);
await settle();
const put = await at(0.337, 0.412);
await sleep(500);
await doubleClick(put.x, put.y);
await settle();
const index = await json('__gm.curve.picked()');
const dropped = (await pointsOf())?.[index];
await drag(put.x, put.y, put.x + 3, put.y - 37, { steps: 8 });
const dragged = (await pointsOf())?.[index];
const magnetOn = await json('__gm.curve.snapButton()');
await click(magnet.x, magnet.y);
await click(aside.x, aside.y);
await undo();
await undo();
const restored = await pointsOf();
check(
'with the magnet on a point is put and dragged onto the 0.05 grid of t and v',
!magnet.toggled &&
magnetOn.toggled &&
!(await json('__gm.curve.snapButton()')).toggled &&
Math.abs(dropped?.x - 0.35) < 1e-6 &&
Math.abs(dropped?.y - 0.4) < 1e-6 &&
onGrid(dragged?.x) &&
onGrid(dragged?.y) &&
dragged.y > dropped.y + 0.04 &&
JSON.stringify(restored) === JSON.stringify(bounced),
JSON.stringify({ magnet, dropped, dragged, restored: restored?.length }),
);
// 53j-ii. Taking a point away (#288): Delete takes the picked one, and a right-click on a point
// opens its menu – Delete point and its type; an end's Delete point is greyed out.
const between = (bounced[0].x + bounced[1].x) / 2;
const onLine = await at(between, expectedAt(bounced, between));
await sleep(500);
await doubleClick(onLine.x, onLine.y);
const added = await pointsOf();
const pickedNew = await json('__gm.curve.picked()');
await press('Delete');
const removed = await pointsOf();
const inner = await at(bounced[2].x, bounced[2].y);
await click(inner.x, inner.y, 'right');
const pointMenu = await json('__gm.menuRows()');
const innerOn = await json('__gm.menuEnabled()');
const pickedByMenu = await json('__gm.curve.picked()');
await press('Escape');
const endAt = await at(0, bounced[0].y);
await click(endAt.x, endAt.y, 'right');
const endOn = await json('__gm.menuEnabled()');
await press('Escape');
await click(inner.x, inner.y, 'right');
await t.clickText('Delete point');
const byMenu = await pointsOf();
await undo();
await undo();
await undo();
await click(aside.x, aside.y);
check(
'a double-click on the line adds a picked point, Delete and the menu of a point take one away, never an end',
added?.length === bounced.length + 1 &&
pickedNew === 1 &&
removed?.length === bounced.length &&
JSON.stringify(pointMenu) ===
JSON.stringify(['Delete point', null, 'Straight', 'Corner', 'Smooth', 'Symmetric']) &&
innerOn?.[0] === true &&
pickedByMenu === 2 &&
endOn?.[0] === false &&
byMenu?.length === bounced.length - 1 &&
JSON.stringify(await pointsOf()) === JSON.stringify(bounced),
JSON.stringify({
added: added?.length,
pickedNew,
removed: removed?.length,
pointMenu,
innerOn,
pickedByMenu,
endOn,
byMenu: byMenu?.length,
}),
);
// 53k. Held, the preview moves only when dragged: its knob sideways sets t, its dot beside the
// graph up or down jumps to the first t the curve reaches that height. The curve stays.
const before = await json('__gm.curve.preview()');
const seventy = await at(0.7, 0);
await drag(before.knob.x, before.knob.y, seventy.x, before.knob.y, { steps: 10 });
const scrubbed = await json('__gm.curve.preview()');
const half = await at(0, 0.5);
await drag(scrubbed.bar.x, scrubbed.bar.y, scrubbed.bar.x, half.y, { steps: 10 });
const jumped = await json('__gm.curve.preview()');
await shot('53-curve-scrub');
const earlier = expectedAt(bounced, Math.max(jumped.t - 0.02, 0));
check(
'dragging the knob puts the preview at that t, dragging its twin jumps to where the curve first reaches that value',
!(await json('__gm.curve.previewButton()')).toggled &&
Math.abs(scrubbed.t - 0.7) < 0.01 &&
Math.abs(scrubbed.v - expectedAt(bounced, scrubbed.t)) < 0.002 &&
Math.abs(jumped.v - 0.5) < 0.01 &&
earlier < 0.5 &&
jumped.t < scrubbed.t &&
JSON.stringify(await pointsOf()) === JSON.stringify(bounced),
JSON.stringify({ before, scrubbed, jumped, earlier }),
);
}