Files
feuerwerk/tools/e2e/43-queries.mjs
T
adminandClaude Opus 5 ffdf0a8ddb
game-maker / gate (push) Successful in 2m4s
game-maker / suite (push) Successful in 7m26s
The new node "Raycast all" answers with every collider along the ray instead of only the nearest one: it carries the same inputs and settings as Raycast (Ignore owner, Hit triggers, Layers from #158) plus "max hits" where 0 means all, and it hands back parallel lists sorted by distance - objects, colliders, points x/y, normals x/y, distances - next to count, so index i of every list speaks about the same hit; a collider counts once no matter how many convex parts its shape is built from, while a tilemap counts per cell it is hit in, and the filter sits in Solids.raycastAll like the one of #158, the one path the editor run and Play both take, so all 12 new unit tests run through describe.each against both systems; the node needed the value system to learn something it could not do - a graph list carried numbers, text, booleans and bundles but no object handles, asList turned an object into its name, so objects/colliders would have been lists of names, which loses the object when two carry the same name, and collider children usually do; Value now knows a travelling list that may hold handles and "Item at" gives such an item out as an object, ready for Destroy, Apply force or Name of, while the single door into a stored file - writeTyped/asTypeOf - runs the list through the new storedList() so every object becomes its name again and nothing living reaches a project file, which is its own unit test over JSON.stringify; three list nodes follow the handles: "Index of" compares objects by identity because a name is not the object of that name, "Set item" and "Fill" write an object as an object, and "Join text" prints the names instead of the innards; this decision was mine to make because the question ran into the MCP timeout - the alternative was a list of names plus "Object by name", which is the smaller change but lossy; 4 new e2e checks in the new block 43e (shield and floor in one go reading 2 and "145, 280", the second object answering to its name, which only a real object in a list can do, "max hits" 1 stopping at the nearest, and the node showing the row) and 18 new unit tests, 12 in raycast.test.ts, 4 on the value system and 2 in the node catalog; two counter-checks hold: not counting tilemap cells one by one drops 2 unit tests, and answering with the nearest hit only drops module 43 with 2 FAILs; one finding on the way - the node read "maxHits" while every other input carries a spoken label, because int() took the key as its label, so it has a third parameter now, and the e2e check caught that, no unit test did (#157)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:54:12 +02:00

454 lines
20 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';
import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* What a game asks the collision about, and platforms one jumps through: one-way colliders (#155),
* the Raycast node (#153) and On collision stay with an interval and contact data (#149). What only a
* running editor shows: the switch in the inspector, the engine and Move landing on a platform and
* passing it from below, the ray drawn in the Scene tab by the editor run, and a stay that fires on
* its own clock.
*/
const FALL = 300;
/** Falls with Move and "Collide with solids"; 1.5 s after it starts it drops through one-way platforms. */
const dropGraph = {
nodes: [
node('qd-update', 'OnUpdate', 0, 0),
node('qd-owner', 'Owner', 0, 160),
node('qd-move', 'Move', 300, 0, { dy: 1, scale: FALL, collide: true }),
node('qd-timer', 'OnTimer', 0, 320, { interval: 1.5 }),
node('qd-drop', 'IgnoreOneWay', 300, 320, { seconds: 0.3 }),
],
edges: [
edge('qd-e1', 'qd-update', 'exec', 'qd-move', 'exec'),
edge('qd-e2', 'qd-update', 'dt', 'qd-move', 'dt'),
edge('qd-e3', 'qd-owner', 'object', 'qd-move', 'object'),
edge('qd-e4', 'qd-timer', 'exec', 'qd-drop', 'exec'),
edge('qd-e5', 'qd-owner', 'object', 'qd-drop', 'object'),
],
};
/** Shoots its owner – a dynamic body below the platform – straight up in On start. */
const jumpGraph = {
nodes: [
node('qj-start', 'OnStart', 0, 0),
node('qj-owner', 'Owner', 0, 160),
node('qj-shoot', 'SetVelocity', 300, 0, { x: 0, y: -700 }),
],
edges: [
edge('qj-e1', 'qj-start', 'exec', 'qj-shoot', 'exec'),
edge('qj-e2', 'qj-owner', 'object', 'qj-shoot', 'object'),
],
};
/** Casts a ray straight down from its owner every frame. */
const eyeGraph = {
nodes: [
node('qe-update', 'OnUpdate', 0, 0),
node('qe-owner', 'Owner', 0, 160),
node('qe-where', 'GetPosition', 250, 160),
node('qe-ray', 'Raycast', 500, 0, { dirX: 0, dirY: 1, length: 500 }),
],
edges: [
edge('qe-e1', 'qe-update', 'exec', 'qe-ray', 'exec'),
edge('qe-e2', 'qe-owner', 'object', 'qe-where', 'object'),
edge('qe-e3', 'qe-where', 'worldX', 'qe-ray', 'fromX'),
edge('qe-e4', 'qe-where', 'worldY', 'qe-ray', 'fromY'),
],
};
/** Casts downwards like `eyeGraph` and logs how far it reached, so the answer needs no drawing. */
const layerEyeGraph = {
nodes: [
...eyeGraph.nodes,
node('qe-log', 'Log', 750, 0),
node('qe-timer', 'OnTimer', 0, 320, { interval: 0.2 }),
],
edges: [
...eyeGraph.edges.filter((e) => e.from.node !== 'qe-update'),
edge('qe-e5', 'qe-timer', 'exec', 'qe-ray', 'exec'),
edge('qe-e6', 'qe-ray', 'exec', 'qe-log', 'exec'),
edge('qe-e7', 'qe-ray', 'distance', 'qe-log', 'message'),
],
};
/**
* Casts downwards like `layerEyeGraph`, but with `Raycast all` (#157): logs how many it met, the
* distances as one line, the name of the object behind the second hit – which only a live object in
* the list can answer – and what a second cast with "max hits" 1 comes back with.
*/
const allEyeGraph = {
nodes: [
node('qa-timer', 'OnTimer', 0, 320, { interval: 0.2 }),
node('qa-owner', 'Owner', 0, 160),
node('qa-where', 'GetPosition', 250, 160),
node('qa-ray', 'RaycastAll', 500, 0, { dirX: 0, dirY: 1, length: 500 }),
node('qa-count', 'Log', 750, 0),
node('qa-distances', 'Log', 1000, 0),
node('qa-second', 'ItemAt', 750, 300, { index: 1 }),
node('qa-name', 'NameOf', 1000, 300),
node('qa-log-name', 'Log', 1250, 0),
node('qa-one', 'RaycastAll', 500, 500, { dirX: 0, dirY: 1, length: 500, maxHits: 1 }),
node('qa-log-one', 'Log', 1500, 0),
],
edges: [
edge('qa-e1', 'qa-timer', 'exec', 'qa-ray', 'exec'),
edge('qa-e2', 'qa-owner', 'object', 'qa-where', 'object'),
edge('qa-e3', 'qa-where', 'worldX', 'qa-ray', 'fromX'),
edge('qa-e4', 'qa-where', 'worldY', 'qa-ray', 'fromY'),
edge('qa-e5', 'qa-ray', 'exec', 'qa-count', 'exec'),
edge('qa-e6', 'qa-ray', 'count', 'qa-count', 'message'),
edge('qa-e7', 'qa-count', 'exec', 'qa-distances', 'exec'),
edge('qa-e8', 'qa-ray', 'distances', 'qa-distances', 'message'),
edge('qa-e9', 'qa-ray', 'objects', 'qa-second', 'list'),
edge('qa-e10', 'qa-second', 'item', 'qa-name', 'object'),
edge('qa-e11', 'qa-distances', 'exec', 'qa-log-name', 'exec'),
edge('qa-e12', 'qa-name', 'name', 'qa-log-name', 'message'),
edge('qa-e13', 'qa-log-name', 'exec', 'qa-one', 'exec'),
edge('qa-e14', 'qa-where', 'worldX', 'qa-one', 'fromX'),
edge('qa-e15', 'qa-where', 'worldY', 'qa-one', 'fromY'),
edge('qa-e16', 'qa-one', 'exec', 'qa-log-one', 'exec'),
edge('qa-e17', 'qa-one', 'distances', 'qa-log-one', 'message'),
],
};
/** Logs how its owner touches the ground (normal y, then impulse) each half second it stays on it. */
const STAY = 0.5;
const feelGraph = {
nodes: [
node('qf-stay', 'OnCollisionStay', 0, 0, { interval: STAY }),
node('qf-log-n', 'Log', 300, 0),
node('qf-log-i', 'Log', 550, 0),
],
edges: [
edge('qf-e1', 'qf-stay', 'exec', 'qf-log-n', 'exec'),
edge('qf-e2', 'qf-stay', 'normalY', 'qf-log-n', 'message'),
edge('qf-e3', 'qf-log-n', 'exec', 'qf-log-i', 'exec'),
edge('qf-e4', 'qf-stay', 'impulse', 'qf-log-i', 'message'),
],
};
export async function queries(t) {
const {
check,
json,
evaluate,
mutate,
select,
click,
object,
settle,
sleep,
play,
stop,
shot,
consoleLog,
} = t;
const Y = LANES.queries;
const GROUND_TOP = Y + 280;
const PLATFORM_TOP = Y + 95;
const setup = (body) =>
mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
const collider = (id, props) =>
assets.push({ id, type: 'collider', name: id, props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false, ...props } });
const behavior = (id, name, graph) => {
const asset = structuredClone(assets.find((a) => a.id === 'mover'));
Object.assign(asset, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
assets.push(asset);
};
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
const o = make(id, assetId, parentId, y, name);
o.position.x = x;
o.overrides = overrides;
scene.objects.push(o);
};
${body}
__gm.state.select(null)`);
const clear = () =>
mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('q-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('q-'));
__gm.state.select(null)`);
const sample = async (seconds, ids) => {
await evaluate(`(() => {
const out = (window.__querySamples = []);
const start = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const y = (id) => w.entities.find((e) => e.objectId === id)?.worldY;
out.push({ t: (performance.now() - start) / 1000, ...Object.fromEntries(${JSON.stringify(ids)}.map((id) => [id, y(id)])) });
if (performance.now() - start < ${seconds * 1000}) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(seconds * 1000 + 300);
return json('window.__querySamples');
};
// 43a. A platform one jumps through (#155), switched to one-way in the inspector. A crate falls
// onto it with Move and drops through 1.5 s later; a dynamic crate shot up from below
// passes it and lands on it.
await setup(`
collider('q-col-ground', { width: 800, height: 40 });
collider('q-col-platform', { width: 300, height: 10 });
collider('q-col-box', {});
assets.push({ id: 'q-rb', type: 'rigidbody', name: 'q-rb', props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } });
behavior('q-drop', 'Query drop', ${JSON.stringify(dropGraph)});
behavior('q-jump', 'Query jump', ${JSON.stringify(jumpGraph)});
const box = { width: 40, height: 40 };
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
put('q-platform', 'crate', '', 0, ${Y + 100}, 'Query platform', { width: 300, height: 10 });
put('q-platform-col', 'q-col-platform', 'q-platform', 0, 0, 'Platform collider');
put('q-dropper', 'crate', '', -100, ${Y}, 'Dropper', box);
put('q-dropper-col', 'q-col-box', 'q-dropper', 0, 0);
put('q-dropper-go', 'q-drop', 'q-dropper', 0, 0);
put('q-jumper', 'crate', '', 100, ${Y + 200}, 'Jumper', box);
put('q-jumper-col', 'q-col-box', 'q-jumper', 0, 0);
put('q-jumper-rb', 'q-rb', 'q-jumper', 0, 0);
put('q-jumper-go', 'q-jump', 'q-jumper', 0, 0);`);
await select({
kind: 'object',
holderId: 'scene1',
objectId: 'q-platform-col',
objectIds: ['q-platform-col'],
});
const toggle = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('One-way')");
// Side and angle mean nothing on a collider blocking from everywhere, so they wait for the switch (#159).
const hidden = await json(
"[__gm.inspectorField('Blocks from'), __gm.inspectorField('One-way angle')].every((f) => f === null)",
);
if (toggle) await click(toggle.x, toggle.y);
await settle();
const blocksFrom = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Blocks from')");
const angleRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('One-way angle')");
check(
'Blocks from and One-way angle show only once One-way is switched on',
hidden === true && Boolean(blocksFrom) && Boolean(angleRow),
JSON.stringify({ hidden, blocksFrom, angleRow }),
);
check(
"a collider's One-way switch in the inspector turns it one-way, blocking from above",
Boolean(toggle) &&
Boolean(blocksFrom) &&
(await object('q-platform-col'))?.overrides?.oneWay === true &&
(await t.has('Above')),
JSON.stringify({ toggle, blocksFrom, overrides: (await object('q-platform-col'))?.overrides }),
);
await select(null);
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
await settle();
await shot('43-one-way');
await play();
const run = await sample(2.8, ['q-dropper', 'q-jumper']);
await stop();
const at = (seconds) =>
run.reduce((best, s) => (Math.abs(s.t - seconds) < Math.abs(best.t - seconds) ? s : best));
const onPlatform = PLATFORM_TOP - 20;
const onGround = GROUND_TOP - 20;
check(
'Move lands on the one-way platform, and Ignore one-way drops it through onto the ground',
run.length > 60 &&
Math.abs(at(0.7)['q-dropper'] - onPlatform) < 0.01 &&
Math.abs(run.at(-1)['q-dropper'] - onGround) < 0.01,
JSON.stringify({ frames: run.length, early: at(0.7), last: run.at(-1), onPlatform, onGround }),
);
const highest = Math.min(...run.map((s) => s['q-jumper']));
check(
'a dynamic body shot up from below passes through the platform and lands on top of it',
highest < PLATFORM_TOP - 60 && Math.abs(run.at(-1)['q-jumper'] - onPlatform) < 1,
JSON.stringify({ highest, last: run.at(-1)['q-jumper'], onPlatform }),
);
await clear();
// 43b. The Raycast node in the editor run (#153): a crate casting down every frame, the ray drawn
// in the Scene tab to where it meets the ground.
await setup(`
collider('q-col-ground', { width: 800, height: 40 });
collider('q-col-box', {});
behavior('q-eye', 'Query eye', ${JSON.stringify(eyeGraph)});
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
put('q-eyebox', 'crate', '', 200, ${Y}, 'Eye', { width: 40, height: 40 });
put('q-eyebox-col', 'q-col-box', 'q-eyebox', 0, 0);
put('q-eyebox-go', 'q-eye', 'q-eyebox', 0, 0, 'Eye logic', { runInEditor: true });`);
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
await sleep(500);
const rays = await json('__gm.rays()');
const ray = rays.at(-1);
check(
'the editor run casts the ray and the Scene tab draws it, down to the ground it met',
(await json('__gm.editorRun()')).running === true &&
ray?.hit === true &&
ray.from.x === 200 &&
ray.from.y === Y &&
Math.abs(ray.to.y - GROUND_TOP) < 1e-6,
JSON.stringify({ count: rays.length, ray, groundTop: GROUND_TOP }),
);
await shot('43-ray');
await clear();
await sleep(300);
// 43c. On collision stay with an interval (#149): a crate resting on the ground logs how it touches
// every half second – the normal from the ground towards it, and the impulse holding it up.
const logFrom = consoleLog.length;
await setup(`
collider('q-col-ground', { width: 800, height: 40 });
collider('q-col-box', {});
assets.push({ id: 'q-rb', type: 'rigidbody', name: 'q-rb', props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } });
behavior('q-feel', 'Query feel', ${JSON.stringify(feelGraph)});
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
put('q-rest', 'crate', '', -300, ${Y + 220}, 'Rester', { width: 40, height: 40 });
put('q-rest-col', 'q-col-box', 'q-rest', 0, 0);
put('q-rest-rb', 'q-rb', 'q-rest', 0, 0);
put('q-rest-go', 'q-feel', 'q-rest', 0, 0, 'Feel');`);
await play();
await sleep(2200);
await stop();
const logged = consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Feel]'))
.map((line) => Number(line.split(']').pop()));
const pairs = Array.from({ length: Math.floor(logged.length / 2) }, (_, i) =>
logged.slice(2 * i, 2 * i + 2),
);
check(
'On collision stay with an interval fires every half second, with the normal and the impulse of the contact',
pairs.length >= 2 && pairs.length <= 4 && pairs.every(([ny, impulse]) => ny === -1 && impulse > 0),
JSON.stringify({ pairs, interval: STAY }),
);
await clear();
// 43d. Which layers a ray looks at (#158). A shield on a layer of its own stands between the
// caster and the ground: with every layer picked the ray stops at the shield, and once that
// layer is unticked in the node's "Layers" setting it goes straight through to the ground.
const SHIELD_TOP = Y + 145;
const reached = () => {
const seen = consoleLog.filter((line) => line.startsWith('[behavior Eye logic]'));
return seen.length === 0 ? null : Number(seen.at(-1).split(']').pop());
};
await setup(`
if (__gm.state.project.collisionLayers.length < 2) __gm.state.project.collisionLayers = ['Default', 'Shots'];
collider('q-col-ground', { width: 800, height: 40 });
collider('q-col-shield', { width: 300, height: 10, layer: 1 });
collider('q-col-box', {});
behavior('q-eye', 'Query eye', ${JSON.stringify(layerEyeGraph)});
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
put('q-shield', 'crate', '', 0, ${Y + 150}, 'Shield', { width: 300, height: 10 });
put('q-shield-col', 'q-col-shield', 'q-shield', 0, 0);
put('q-eyebox', 'crate', '', 0, ${Y}, 'Eye', { width: 40, height: 40 });
put('q-eyebox-col', 'q-col-box', 'q-eyebox', 0, 0);
put('q-eyebox-go', 'q-eye', 'q-eyebox', 0, 0, 'Eye logic', { runInEditor: true });`);
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
await sleep(500);
const blocked = reached();
check(
'a ray looking at every layer stops at the shield, whatever layer it is on',
blocked === SHIELD_TOP - Y,
JSON.stringify({ reached: blocked, toShield: SHIELD_TOP - Y, toGround: GROUND_TOP - Y }),
);
await select({ kind: 'node', assetId: 'q-eye', nodeId: 'qe-ray', nodeIds: ['qe-ray'] });
const layersRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Layers')");
check(
'the Raycast node carries a Layers setting, reading as every layer',
Boolean(layersRow) && (await t.has('All layers')),
JSON.stringify({ layersRow, allLayers: await t.has('All layers') }),
);
await click(layersRow.x, layersRow.y);
const layerTicks = await json('__gm.menuRows()');
const names = await json('__gm.state.project.collisionLayers');
check(
'the setting lists the layers of the project, all of them ticked',
layerTicks.length === names.length && layerTicks.every((row) => row.startsWith('☑')),
JSON.stringify({ layerTicks, names }),
);
await t.clickText(layerTicks[1]);
await shot('43-ray-layers');
await t.press('Escape');
await settle();
const settings = await json("__gm.graph.nodeSettings('qe-ray')");
check(
'the node itself spells the mask out by name rather than as the bits it is stored in',
settings.includes(`Layers: ${names[0]}`),
JSON.stringify(settings),
);
// A running instance keeps the graph it started with – measured here: the setting stands in the
// project at once, what the node casts does not. So the behavior is switched off and on, which
// ends the instance and builds a new one on the graph as it now stands.
await select({ kind: 'object', holderId: 'scene1', objectId: 'q-eyebox-go', objectIds: ['q-eyebox-go'] });
for (let i = 0; i < 2; i++) {
const runRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Run in editor')");
await click(runRow.x, runRow.y);
await settle();
}
await sleep(600);
const through = reached();
check(
"unticking the shield's layer lets the ray pass it and meet the ground behind it",
through === GROUND_TOP - Y,
JSON.stringify({ reached: through, toGround: GROUND_TOP - Y, toShield: SHIELD_TOP - Y }),
);
await select(null);
await clear();
// 43e. Every hit on the line (#157): the same shield and ground, cast through by `Raycast all`.
// What it met comes out as parallel lists – the count, the distances, and the object behind
// the second hit, which only a live object in the list can be asked for.
const lastLines = (n) =>
consoleLog
.filter((line) => line.startsWith('[behavior All eyes]'))
.slice(-n)
.map((line) => line.split(']').pop().trim());
await setup(`
collider('q-col-ground', { width: 800, height: 40 });
collider('q-col-shield', { width: 300, height: 10 });
collider('q-col-box', {});
behavior('q-all', 'Query all', ${JSON.stringify(allEyeGraph)});
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
put('q-shield', 'crate', '', 0, ${Y + 150}, 'Shield', { width: 300, height: 10 });
put('q-shield-col', 'q-col-shield', 'q-shield', 0, 0);
put('q-eyebox', 'crate', '', 0, ${Y}, 'Eye', { width: 40, height: 40 });
put('q-eyebox-col', 'q-col-box', 'q-eyebox', 0, 0);
put('q-eyebox-go', 'q-all', 'q-eyebox', 0, 0, 'All eyes', { runInEditor: true });`);
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
await sleep(700);
const [count, distances, behind, limited] = lastLines(4);
check(
'Raycast all meets the shield and the ground behind it, nearest first',
count === '2' && distances === `${SHIELD_TOP - Y}, ${GROUND_TOP - Y}`,
JSON.stringify({ count, distances, toShield: SHIELD_TOP - Y, toGround: GROUND_TOP - Y }),
);
check(
'the objects it met are objects, not their names: the second one answers where it is',
behind === 'Query ground',
JSON.stringify({ behind, lines: lastLines(4) }),
);
check(
'"max hits" 1 keeps the nearest of them and stops there',
limited === String(SHIELD_TOP - Y),
JSON.stringify({ limited, toShield: SHIELD_TOP - Y }),
);
await select({ kind: 'node', assetId: 'q-all', nodeId: 'qa-ray', nodeIds: ['qa-ray'] });
await settle();
const allInputs = await json("__gm.graph.nodeInputs('qa-ray')");
const allSettings = await json("__gm.graph.nodeSettings('qa-ray')");
check(
'the node takes a "max hits" of its own and carries the same Layers setting as Raycast (#158)',
allInputs.some((row) => row.startsWith('max hits')) && allSettings.includes('Layers: All layers'),
JSON.stringify({ allInputs, allSettings }),
);
await shot('43-ray-all');
await select(null);
await clear();
}