diff --git a/.gitignore b/.gitignore
index 41501c38..2f7b907e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
node_modules/
dist/
-index.html
+/index.html
data/projects/*.json
data/projects/*.tmp
# Uploaded images are user data like the projects: content-addressed, written by the API,
diff --git a/README.md b/README.md
index 90b60f36..3f109ac6 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,8 @@ Live: · Widget playground: .ts
-src/core/store/ serialize (untrusted JSON → Project), http (one API call), HttpProjectStore
+ variablePorts, propertyPorts, lookup (behavior programs), keyRepeat; nodes/{events,flow,data,action,variable}/.ts
+src/core/store/ serialize (untrusted JSON → Project), http (one API call), ProjectStore/HttpProjectStore, TextureStore/HttpTextureStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, AnimationSystem, BehaviorSystem
src/ui/ widget toolkit (generic, knows nothing about assets)
src/viewport/ view (transforms), items (what to draw), symbols (how to draw it),
- textureFill, TextureLibrary – no editor concepts, so tests reach it freely
+ textureFill, TextureLibrary, activeCamera – no editor concepts, so tests reach it freely
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave, textureImport,
framing (fit/marquee/snap maths), AssetTabEditor (the shared shell of the three asset tabs)
src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editing tools, hence not in src/viewport/
-src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command), valueSpecs
+src/editor/inspector/ Inspector panel, host (what a section builds into), field factory, previewHandlers (scrub → preview → command), valueSpecs
(where a value lives – shared with the List tab),
one section per selection kind (asset/object/node/frame), variableRows (one row shape for
every variable), portSection, aspectAction, objectQueries/vectorRows/parentRow
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, EdgeLayer (curves and fold marks), graphMenus,
- NodeView, NodePalette + paletteEntries, folding (hidden links, collapsed nodes)
+ NodeView, NodePalette + paletteEntries, folding (hidden links, collapsed nodes), Breadcrumb + trail (the path into nested behaviors), beads (the run's pulses)
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths),
ImagePicker + imageGrid (the "+ → Texture" dialog), checker (the transparency board)
src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry)
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
api/ PHP backend data/projects/ persisted projects
-tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, remote/
+tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, assets/ (the walk demo's sheet)
```
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
@@ -740,7 +740,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
./check.sh # the same, ending in `=== CHECK EXIT n ===`: piped into `tail` npm's exit
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
-export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
+./build.sh # the same under `set -o pipefail`, for callers that pipe the output
node tools/seed-e2e.mjs && node tools/e2e.mjs # 633 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
@@ -775,29 +775,6 @@ its caller. It takes the same `GM_STEPS` argument, refuses to start a second run
nothing is running, the newest log's result plus its FAIL lines. A log without a closing line is
reported as `ABORTED`, not as its PASS count: a run that was cut short is not a result.
-**`GM_REMOTE=user@host` moves the browser to another machine** – `tools/lib/browser.mjs` starts it
-over SSH and forwards its CDP port back to `127.0.0.1`, so the harness itself is unchanged. The
-checks load the public base URL, so the remote side needs only a browser and internet: no repo, no
-node. Setup is an SSH key (`GM_REMOTE_KEY`, default `~/.ssh/gm-win.key`). Measured against a Windows
-desktop with an RTX 4070: **the full suite in ~130 s and 24 smoke checks in 16 s, with this machine
-idle** (0.4 s of CPU for the whole smoke run, load average 0.04). Worth setting while the box is
-busy with something else; it is not required, and an unreachable host is no reason to skip a run.
-
-`tools/remote/gm-browser.ps1` is copied over on every run, and getting the browser to die with the
-run took three tries, because two obvious leashes do not hold on Windows:
-
-- **stdin is not a signal.** Only the first chunk a caller writes ever reaches the remote command,
- so a heartbeat over stdin is indistinguishable from a dead caller – it killed a run at check 44.
-- **A dropped connection does not kill the remote command.** Windows OpenSSH leaves it running;
- measured, six orphaned `chrome.exe` survived a SIGKILLed tunnel.
-
-What holds is the CDP port: while a run is under way the forwarded connection is established, and it
-closes the moment the run or the tunnel dies. The launcher watches for that with .NET rather than
-`Get-NetTCPConnection` – that one goes through WMI, which is denied to a non-admin account, and its
-broken CIM session took a browser down mid-run. A job object with `KILL_ON_JOB_CLOSE` catches the
-rest, and a 30-minute cap bounds anything still unforeseen. Measured: the browser survives 80 s of an
-idle held connection and is gone 44 s after the local node process is killed with `-9`.
-
**Prettier owns the formatting** (110 columns, single quotes, `.prettierrc.json`); Markdown is
excluded because it pads table cells to equal width. ESLint therefore only ever means "this is a
mistake", never "this looks different": `eslint-config-prettier` switches every stylistic rule
@@ -906,6 +883,10 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) |
| DELETE | `textures/{file}` | 204 |
+Every project document carries `formatVersion` (`PROJECT_FORMAT_VERSION` in `core/model/Project.ts`,
+currently 2). There are no load-time migrations: `serialize.ts` reads what it recognises and drops
+the rest.
+
Uploads send the bytes as the body rather than `multipart/form-data`: no boundary parsing in
PHP, and PHP's `upload_max_filesize` (2 MB) never applies — only `post_max_size`. The API has its
own caps behind nginx's: 8 MB per request, 4 MB per image (413). The stored
@@ -925,7 +906,7 @@ fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that i
step-1 field lands on whole numbers), `↑`/`↓` step, shown with three decimals (`ui/math.ts`,
`NUMBER_DECIMALS`) so a fractional value never reads as a whole number — except where the
value is integral by nature (frame rectangles, the slice dialog, a camera's view size, font
-size, the key-repeat milliseconds), which set
+size, the key-repeat milliseconds, the index and count ports of list and text nodes), which set
`NumberField`'s `decimals: 0` and show plain whole numbers · viewport: wheel zooms,
middle-drag or `Shift`+drag pans, drag on empty space rubber-bands, `Ctrl`/`Shift`-click toggles an
object in the selection (hierarchy rows too).
diff --git a/api/index.php b/api/index.php
index 211acfd3..35ab456c 100644
--- a/api/index.php
+++ b/api/index.php
@@ -39,7 +39,6 @@ function routeProjects(array $parts, string $method): never
{
$store = new Store(dirname(__DIR__) . '/data/projects');
- // Collection: /projects
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list());
@@ -55,7 +54,6 @@ function routeProjects(array $parts, string $method): never
Response::error('Method not allowed', 405);
}
- // Item: /projects/{id}
$id = $parts[1];
if (!Store::isValidId($id)) {
Response::error('Invalid project id', 400);
@@ -100,7 +98,6 @@ function routeTextures(array $parts, string $method): never
{
$store = new TextureStore(dirname(__DIR__) . '/data/textures');
- // Collection: /textures
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list());
@@ -119,7 +116,6 @@ function routeTextures(array $parts, string $method): never
Response::error('Method not allowed', 405);
}
- // Item: /textures/{file}
if ($method === 'DELETE') {
if (!$store->delete($parts[1])) {
Response::error('Texture not found', 404);
diff --git a/package.json b/package.json
index 38d0e055..233da6e5 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,6 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
- "preview": "vite preview",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts
index 509bc77b..186a1d7e 100644
--- a/src/core/graph/NodeContext.ts
+++ b/src/core/graph/NodeContext.ts
@@ -49,7 +49,6 @@ export interface PointerState {
y: number;
inside: boolean;
down: boolean;
- /** Went down / up during this frame. */
pressed: boolean;
released: boolean;
}
diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts
index 0f662352..dd6aa0fc 100644
--- a/src/core/graph/nodes/index.ts
+++ b/src/core/graph/nodes/index.ts
@@ -83,7 +83,6 @@ import { Variables } from './variable/Variables';
* the order here is documentation only.
*/
export const BUILT_IN_NODES: readonly NodeDef[] = [
- // events
OnStart,
OnUpdate,
OnKeyDown,
@@ -96,15 +95,12 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnClick,
OnAnimationFinished,
BehaviorStart,
- // flow
Branch,
Sequence,
Repeat,
RunBehavior,
BehaviorEnd,
- // variables
Variables,
- // data: references and properties
Self,
Owner,
Parent,
@@ -114,7 +110,6 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
IsEnabled,
Properties,
GetPosition,
- // data: literals and math
NumberLiteral,
BooleanLiteral,
StringLiteral,
@@ -134,7 +129,6 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
Modulo,
Random,
Time,
- // data: text and comparison
ToText,
ToNumber,
Join,
@@ -145,7 +139,6 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
And,
Or,
Not,
- // data: lists
ListLiteral,
Fill,
Count,
@@ -154,10 +147,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
SubList,
Concat,
IndexOf,
- // data: structs
PackStruct,
ExpandStruct,
- // data: vectors and input
Length,
Distance,
Normalize,
@@ -167,7 +158,6 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
MouseButton,
IsHovered,
IsTouching,
- // actions
Move,
SetPosition,
Spawn,
diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts
index d24be6a8..32ea6b82 100644
--- a/src/core/model/Project.ts
+++ b/src/core/model/Project.ts
@@ -1,9 +1,6 @@
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
-/**
- * 2 since a placed container stopped copying its template into the scene. Old files are not
- * migrated: the loader simply reads no container content from them any more.
- */
+/** 2: a placed container carries no copy of its template. */
export const PROJECT_FORMAT_VERSION = 2;
export interface Project {
diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts
index ed0b7375..272f5e42 100644
--- a/src/core/model/animation.ts
+++ b/src/core/model/animation.ts
@@ -47,7 +47,6 @@ export function animationFrameAt(asset: AnimationAsset, time: number): FrameAt {
return frameAt(asset.frames.length, fpsOf(asset), playbackOf(asset), time);
}
-/** Seconds for one pass through the frames. */
export function animationDuration(asset: AnimationAsset): number {
const fps = fpsOf(asset);
return fps > 0 ? asset.frames.length / fps : 0;
diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts
index aa8cea06..29ab0698 100644
--- a/src/core/model/texture.ts
+++ b/src/core/model/texture.ts
@@ -116,7 +116,6 @@ export interface SliceOptions {
/** Pixels skipped at the left/top edge of the sheet before the first cell. */
offsetX: number;
offsetY: number;
- /** Pixels between neighbouring cells. */
spacingX: number;
spacingY: number;
}
diff --git a/src/core/properties/camera.ts b/src/core/properties/camera.ts
index 9a3bc2da..57201e47 100644
--- a/src/core/properties/camera.ts
+++ b/src/core/properties/camera.ts
@@ -3,7 +3,6 @@ import { defaultsFor } from './PropertySchema';
const DEFAULTS = defaultsFor('camera');
-/** Axis-aligned rectangle in world units. */
export interface WorldRect {
left: number;
top: number;
diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts
index 447d0ccd..5a2804ed 100644
--- a/src/core/properties/collider.ts
+++ b/src/core/properties/collider.ts
@@ -17,7 +17,6 @@ type Circle = Extract;
/** A movement axis in the plane (`z` is draw order, not geometry). */
export type PlaneAxis = 'x' | 'y';
-/** Centred on `centre`, a world position. */
export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
if (props.shape === 'circle')
return {
diff --git a/src/core/properties/listItems.ts b/src/core/properties/listItems.ts
index dcf6f1b1..5bef7888 100644
--- a/src/core/properties/listItems.ts
+++ b/src/core/properties/listItems.ts
@@ -3,8 +3,7 @@
* value. JSON says what a number, a text, a boolean and a struct are; a tagged kind says so itself
* (`tagOf`), which keeps every list ever stored readable.
*
- * Depth is capped the way the declarations used to cap it: a struct item holds flat items, and so
- * does a list inside a struct field.
+ * Depth is capped: a struct item holds flat items, and so does a list inside a struct field.
*/
import {
type FieldValue,
@@ -75,7 +74,7 @@ export function listItemOf(value: PropertyValue | undefined): ListItem {
/**
* A flat position holds a primitive or a tagged item. Anything deeper is dropped rather than
- * flattened – the same bound the declarations used to draw, and the one the store writes.
+ * flattened – the bound the store writes.
*/
export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined {
if (isList(value) || (isStruct(value) && !isTaggedKind(tagOf(value) ?? ''))) return undefined;
diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts
index a9e9517d..bd00a0ca 100644
--- a/src/editor/inspector/fieldFactory.ts
+++ b/src/editor/inspector/fieldFactory.ts
@@ -69,7 +69,7 @@ export interface FieldRow {
/**
* The inspector's one column grid. A variable row is mark + name, a plain row a label spanning
- * both, so every value starts at the same x. The mark column holds `≡Aa` (24 px measured).
+ * both, so every value starts at the same x. The mark column fits `≡Aa` (24 px measured) with air.
*/
export const MARK_WIDTH = 28;
export const NAME_WIDTH = 90;
diff --git a/src/runtime/World.ts b/src/runtime/World.ts
index 2b416207..eb125735 100644
--- a/src/runtime/World.ts
+++ b/src/runtime/World.ts
@@ -208,7 +208,6 @@ export class World {
return this;
}
- /** Advances the simulation by `dt` seconds. */
tick(dt: number): void {
this.elapsed += dt;
for (const system of this.systems) system.update(this, dt);
diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts
index 52baa9b1..3d9cddde 100644
--- a/src/ui/widgets/ListView.ts
+++ b/src/ui/widgets/ListView.ts
@@ -9,7 +9,6 @@ import type { Popup } from './Popup';
export interface ListItem {
id: string;
label: string;
- /** Small secondary text on the right. */
hint?: string;
/** Left indent in levels. */
depth?: number;
diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts
index f1f45345..8314dd5b 100644
--- a/src/ui/widgets/TextField.ts
+++ b/src/ui/widgets/TextField.ts
@@ -11,7 +11,6 @@ export interface TextFieldOptions {
placeholder?: string;
style?: TextStyleToken;
width?: number;
- /** Fires while typing. */
onInput?: (value: string) => void;
/** Fires on Enter / blur when the value changed since editing started. */
onChange?: (value: string) => void;
diff --git a/tests/core/collider.test.ts b/tests/core/collider.test.ts
index 6da22fbd..52aeeeae 100644
--- a/tests/core/collider.test.ts
+++ b/tests/core/collider.test.ts
@@ -159,7 +159,7 @@ describe('move and slide', () => {
it('stops flush at a solid collider and reports the blocked axis', () => {
const { world, collisions, crateA } = runtime();
- // Crates are 100 wide, 300 apart: 250 of travel is possible before they touch (centres 100 apart).
+ // Crates are 100 wide, 300 apart: 200 of travel before they touch (centres 100 apart).
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200);
expect(collisions.moveAndSlide(world, crateA, 50, 0)).toEqual({ blockedX: true, blockedY: false });
diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts
index 3007bbd2..0e9a63a5 100644
--- a/tests/core/node-catalog.test.ts
+++ b/tests/core/node-catalog.test.ts
@@ -56,8 +56,6 @@ const pointer = (overrides: Partial): Partial = {
- // flow
-
Branch: [
{ name: 'true takes then', inputs: { condition: true }, next: 'then' },
{ name: 'false takes else', inputs: { condition: false }, next: 'else' },
@@ -104,8 +102,6 @@ const CASES: Record = {
},
],
- // variables
-
// Reading needs the interpreter, which asks `read` at the moment: see variable-node.test.ts.
Variables: [
{
@@ -145,8 +141,6 @@ const CASES: Record = {
},
],
- // references and properties
-
Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }],
Owner: [
{ name: 'outputs the object the behavior is parented to', outputs: { object: fakeContext().owner } },
@@ -217,8 +211,6 @@ const CASES: Record = {
},
],
- // literals and math
-
Number: [
{ name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } },
{ name: 'unset is 0', outputs: { value: 0 } },
@@ -290,8 +282,6 @@ const CASES: Record = {
},
],
- // text and comparison
-
ToText: [
{ name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } },
{ name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } },
@@ -318,7 +308,6 @@ const CASES: Record = {
outputs: { text: 'b' },
},
],
- // lists
List: [
{ name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } },
@@ -445,8 +434,6 @@ const CASES: Record = {
{ name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } },
],
- // vectors and input
-
Length: [
{ name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } },
{ name: 'the zero vector has length 0', inputs: { x: 0, y: 0 }, outputs: { result: 0 } },
@@ -569,8 +556,6 @@ const CASES: Record = {
},
],
- // actions
-
Move: [
{
name: 'moves by (dx, dy) × scale × dt',
diff --git a/tests/core/texture-kind.test.ts b/tests/core/texture-kind.test.ts
index ce7471da..b65a6d81 100644
--- a/tests/core/texture-kind.test.ts
+++ b/tests/core/texture-kind.test.ts
@@ -91,7 +91,7 @@ describe('a declared port of kind color or texture', () => {
expect(portDefOf({ key: 'n', kind: 'number' }).field).toBeUndefined();
});
- /** A file from before the kind existed declares a sprite's `color` port as a number; it still is one. */
+ /** A `color` port declared as a number still names the property: colour travels as a number. */
it('names a property when it travels the way the property does', () => {
expect(isPortOf({ key: 'color', kind: 'color' }, COLOR)).toBe(true);
expect(isPortOf({ key: 'color', kind: 'number' }, COLOR)).toBe(true);
diff --git a/tests/editor/image-grid.test.ts b/tests/editor/image-grid.test.ts
index 7197dec8..000d0c67 100644
--- a/tests/editor/image-grid.test.ts
+++ b/tests/editor/image-grid.test.ts
@@ -10,7 +10,6 @@ import {
tileRect,
} from '../../src/editor/texture/imageGrid';
-/** The width the picker gives its grid. */
const WIDTH = 560;
describe('gridMetrics', () => {
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 2184d206..709cd2ea 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -48,7 +48,6 @@ export interface SceneFixture extends ProjectFixture {
world(id: string): Position;
}
-/** An empty project with an undo stack. */
export function makeProject(): ProjectFixture {
const project = createEmptyProject('p', 'P');
return { project, stack: new CommandStack(project) };
diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs
index f5cfd9a3..4edeb7db 100644
--- a/tools/e2e/02-inspector.mjs
+++ b/tools/e2e/02-inspector.mjs
@@ -25,7 +25,7 @@ export async function inspector(t) {
projectId,
} = t;
- // 1. Select the blue crate in the hierarchy.
+ // 2a. Select the blue crate in the hierarchy.
await clickText('Blue crate');
let s = await state();
check(
@@ -35,7 +35,7 @@ export async function inspector(t) {
);
await shot('02-object-selected');
- // 1b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
+ // 2b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
// crate overrides width and color, the camera and the red crate's subtree nothing.
const GOLD = '#f2c84b';
const PLAIN = '#e6e8ee';
@@ -57,7 +57,7 @@ export async function inspector(t) {
`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = { width: 140, color: 0x4488ff }`,
);
- // 2. Rename via inspector: the name field sits right of the "Name" label.
+ // 2c. Rename via inspector: the name field sits right of the "Name" label.
const nameLabel = await locate('Name');
await click(nameLabel.x + 160, nameLabel.y);
await press('a', { ctrl: true });
@@ -72,7 +72,7 @@ export async function inspector(t) {
);
await shot('04-renamed');
- // 2b. A double-click into a text field selects its content, so the next keystroke replaces the
+ // 2d. A double-click into a text field selects its content, so the next keystroke replaces the
// whole name instead of inserting into it. Renamed back, since later checks go by the name.
await doubleClick(nameLabel.x + 160, nameLabel.y);
await typeText('Twice');
@@ -83,7 +83,7 @@ export async function inspector(t) {
(await object('o2')).name === 'Twice',
JSON.stringify((await object('o2')).name),
);
- // Undone rather than typed back: check 6 further down counts its undos, so this must not
+ // Undone rather than typed back: check 2h further down counts its undos, so this must not
// leave a step on the stack.
await undo();
await sleep(200);
@@ -93,14 +93,14 @@ export async function inspector(t) {
JSON.stringify((await object('o2')).name),
);
- // 3. Scrub the X field (right of the "X" label) by 100 px.
+ // 2e. Scrub the X field (right of the "X" label) by 100 px.
const xLabel = await locate('X');
await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);
const xNow = (await object('o2')).position.x;
check('scrubbing X moved the object', xNow > 120, `x=${xNow}`);
await shot('05-scrubbed');
- // 3b. Type into X, then click straight into the empty viewport: the typed value must be
+ // 2f. Type into X, then click straight into the empty viewport: the typed value must be
// committed (not reverted) even though the click deselects and rebuilds the inspector.
const center = await json('__gm.viewportCenter()');
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
@@ -114,7 +114,7 @@ export async function inspector(t) {
`x=${xTyped} selection=${JSON.stringify(s.selection)}`,
);
- // 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
+ // 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
// on the origin, so compute its screen position from the viewport centre.
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
const o1Now = (await object('o1')).position;
@@ -129,7 +129,7 @@ export async function inspector(t) {
check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides));
check('save status is saved', (await state()).save === 'saved');
- // 6. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
+ // 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus
for (let i = 0; i < 4; i++) await undo();
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
@@ -142,7 +142,7 @@ export async function inspector(t) {
check('redo restored the rename', (await object('o2')).name === 'Renamed crate');
await shot('07-undo-redo');
- // 7. A change to the *structure* of what is shown normally rebuilds the inspector, which
+ // 2i. A change to the *structure* of what is shown normally rebuilds the inspector, which
// destroys the widget being typed in. While a field in here has focus the rebuild has to
// wait – otherwise a field whose value decides the structure cannot be typed into at all.
await clickText('Renamed crate');
diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs
index 92c0a2de..fa85a05e 100644
--- a/tools/e2e/03-play.mjs
+++ b/tools/e2e/03-play.mjs
@@ -31,7 +31,7 @@ export async function play(t) {
const gameInfo = () => json('__gm.gameInfo()');
const play = t.play;
- // 7. Play: both crates lie inside the 640×360 frame at the origin; the behavior symbol is
+ // 3a. Play: both crates lie inside the 640×360 frame at the origin; the behavior symbol is
// editor-only. The "Mover" behavior owns the red crate: holding ArrowRight moves it in the
// world (Axis input × speed × dt), Esc stops without touching the model.
await clickText('Renamed crate');
@@ -72,7 +72,7 @@ export async function play(t) {
`${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`,
);
- // 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
+ // 3b. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
// copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with
// the Delete key change the world at once, undo brings it back, Esc restores the saved model.
await play();
@@ -151,7 +151,7 @@ export async function play(t) {
);
check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene');
- // 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
+ // 3c. Culling: move the blue crate far outside the camera frame → only one item visible.
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000`);
await play();
info = await gameInfo();
@@ -159,7 +159,7 @@ export async function play(t) {
await stop();
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}`);
- // 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
+ // 3d. Without a camera nothing is rendered: delete the camera object, play, then undo.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
await press('Delete');
await sleep(200);
@@ -178,7 +178,7 @@ export async function play(t) {
await undo();
check('undo restored the camera object', (await objects()).length === 7);
- // 7d. Which camera renders: a camera that is switched off is skipped like any other object, and
+ // 3e. Which camera renders: a camera that is switched off is skipped like any other object, and
// of those left the first in hierarchy order takes the view. The second camera is parked far
// from the origin, so "seen through it" is provable – both crates fall outside its frame.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
@@ -242,7 +242,7 @@ export async function play(t) {
JSON.stringify((await object('o3')).overrides),
);
- // 7e. Not playing, the Game tab previews the scene through the same camera instead of waiting
+ // 3f. Not playing, the Game tab previews the scene through the same camera instead of waiting
// for Play. Proven by comparing it with what Play then draws – identical readout and an
// identical pixel on the red crate – so "it renders something" cannot pass for a preview.
const crateSpot = async () => {
diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs
index b69bc050..3364c9f6 100644
--- a/tools/e2e/04-assets.mjs
+++ b/tools/e2e/04-assets.mjs
@@ -19,7 +19,7 @@ export async function assets(t) {
asset,
} = t;
- // 8. Context menu on the Crate asset. Placing left the menu when it became a drag.
+ // 4a. Context menu on the Crate asset. Placing left the menu when it became a drag.
await clickText('Crate', 'right');
check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`)));
check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`)));
@@ -27,7 +27,7 @@ export async function assets(t) {
await press('Escape');
await sleep(150);
- // 8b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
+ // 4b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
// with `drag()`, because the preview only exists while the button is still down.
const from = await json("__gm.assetRow('crate')");
const centre = await json('__gm.viewportCenter()');
@@ -73,7 +73,7 @@ export async function assets(t) {
await sleep(150);
check('redo puts it back', (await objects()).length === 8);
- // 9. Create a new asset through the + menu.
+ // 4c. Create a new asset through the + menu.
await clickText('+');
await clickText('Sprite');
await sleep(100);
@@ -81,7 +81,7 @@ export async function assets(t) {
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
await shot('12-new-asset');
- // 9b. Double-click on a scene row: the one thing activating an asset still does since placing
+ // 4d. Double-click on a scene row: the one thing activating an asset still does since placing
// became a drag. The second scene goes in through the model – the "+" menu's "Scene" entry
// shares its label with the Scene tab, so a click by text could land on either.
await mutate(
@@ -103,7 +103,7 @@ export async function assets(t) {
);
await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`);
- // 12. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour,
+ // 4e. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour,
// so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's
// reset is the first on screen and colour's the last.
await select({ kind: 'asset', assetId: 'crate' });
@@ -144,7 +144,7 @@ export async function assets(t) {
JSON.stringify(await evaluate(`__gm.locate('↺')`)),
);
- // 12b. The same gold in the asset list: a row is marked while the asset carries anything the
+ // 4f. The same gold in the asset list: a row is marked while the asset carries anything the
// inspector marks. The Crate deviates twice, so it takes two resets to turn it plain.
const GOLD = '#f2c84b';
const PLAIN = '#e6e8ee';
diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs
index a7b4c9e0..f10d6050 100644
--- a/tools/e2e/05-drawing.mjs
+++ b/tools/e2e/05-drawing.mjs
@@ -3,7 +3,7 @@ export async function drawing(t) {
const { check, sleep, json, shot, click, locate, typeText, press, undo, mutate, select, object } = t;
const orderOfCrates = () => json(`__gm.drawOrder().filter(id => id === 'o1' || id === 'o2')`);
- // 10. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
+ // 5a. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
// right on top of its icon → the icon must still be drawn above the sprite.
await mutate(`
const objs = __gm.state.activeScene.objects;
@@ -20,7 +20,7 @@ export async function drawing(t) {
);
await shot('13-symbol-on-top');
- // 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins
+ // 5b. Z-order: put both crates on the origin. With equal z the hierarchy order wins
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
await mutate(
`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`,
diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs
index e370bb68..caecabda 100644
--- a/tools/e2e/06-graph.mjs
+++ b/tools/e2e/06-graph.mjs
@@ -34,7 +34,7 @@ export async function graph(t) {
};
const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(',');
- // 13. Selecting the Mover asset binds the graph; the Graph tab shows it.
+ // 6a. Selecting the Mover asset binds the graph; the Graph tab shows it.
await clickText('Mover');
await evaluate(`__gm.showTab('graph')`);
await sleep(250);
@@ -44,7 +44,7 @@ export async function graph(t) {
);
await shot('16-graph');
- // 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
+ // 6b. Drag a node: clicking its title selects it, dragging moves it (undoable).
const axisBefore = await nodeOf('n-axis');
const axisPos = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40);
@@ -71,7 +71,7 @@ export async function graph(t) {
`${axisUndone.x},${axisUndone.y}`,
);
- // 13b. Typing a query and then *clicking* a row adds exactly one node: the search field's
+ // 6c. Typing a query and then *clicking* a row adds exactly one node: the search field's
// blur-commit must not pick the first match as well.
const gcenter = await json('__gm.graph.center()');
const beforeClickPick = await graphCounts();
@@ -93,7 +93,7 @@ export async function graph(t) {
await undo(); // so the next step finds the same empty spot and the expected graph
check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes);
- // 13c. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
+ // 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
const before13 = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await sleep(100);
@@ -112,7 +112,7 @@ export async function graph(t) {
);
await shot('17-node-added');
- // 13d. Connect: drag from the Move node's exec output onto the Log node's exec input. An
+ // 6e. Connect: drag from the Move node's exec output onto the Log node's exec input. An
// exec output must not connect to a data input: dragging onto "message" does nothing.
const fromPort = await json(`__gm.graph.portAt('n-move', 'exec', true)`);
const toPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false)`);
@@ -231,7 +231,7 @@ export async function graph(t) {
JSON.stringify(cancelled),
);
- // 13e. Delete removes the selected node together with its edge; undo brings both back.
+ // 6f. Delete removes the selected node together with its edge; undo brings both back.
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id });
await sleep(200);
check('the node inspector has no delete button of its own', !(await t.has('Delete node')));
@@ -251,7 +251,7 @@ export async function graph(t) {
JSON.stringify(restored),
);
- // 13f. The fps readout runs in both modes. Play always starts on the Game tab; switching to
+ // 6g. The fps readout runs in both modes. Play always starts on the Game tab; switching to
// Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot.
const fpsEdit = await evaluate('__gm.fps()');
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
@@ -281,7 +281,7 @@ export async function graph(t) {
await holding;
await stop();
- // 13g. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
+ // 6h. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
await select({ kind: 'asset', assetId: 'mover' });
await sleep(150);
await clickText('+ Variable');
@@ -296,7 +296,7 @@ export async function graph(t) {
await undo();
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
- // 13h. The tab keeps its subject: selecting something else leaves the graph alone, and the
+ // 6i. The tab keeps its subject: selecting something else leaves the graph alone, and the
// header picker switches behaviors without any selection at all.
const size = await graphCounts();
const readout = `${size.nodes} nodes · ${size.edges} links`;
@@ -337,7 +337,7 @@ export async function graph(t) {
(await evaluate(`__gm.graph.current()`)) === 'mover',
);
- // 13i. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
+ // 6j. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
// under it included – and the left button no longer pans at all.
await clickText('Fit');
await sleep(250);
@@ -374,7 +374,7 @@ export async function graph(t) {
JSON.stringify({ axisBeforeBand, axisAfterBand }),
);
- // 13j. A rubber band across the whole canvas takes every node – the count comes from the model,
+ // 6k. A rubber band across the whole canvas takes every node – the count comes from the model,
// so this cannot agree with the canvas by sharing its rule.
const nodeCount = (await asset('mover')).graph.nodes.length;
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
@@ -386,7 +386,7 @@ export async function graph(t) {
);
await shot('21-graph-multiselect');
- // 13k. The whole selection moves as one, and one undo takes all of it back.
+ // 6l. The whole selection moves as one, and one undo takes all of it back.
const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
const grabAt = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
@@ -479,7 +479,7 @@ export async function graph(t) {
await undo();
await sleep(150);
- // 13l. Delete acts on the selection, not just the primary node.
+ // 6m. Delete acts on the selection, not just the primary node.
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const beforeDelete = await graphCounts();
await press('Delete');
@@ -498,7 +498,7 @@ export async function graph(t) {
JSON.stringify(restoredAll),
);
- // 13m. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
+ // 6n. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
// and a click cannot miss one that the earlier pan pushed over the edge.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
@@ -520,7 +520,7 @@ export async function graph(t) {
JSON.stringify(await json('__gm.graph.selected()')),
);
- // 13n. Fit really fits: proven against a node parked far outside, and against the fact that the
+ // 6o. Fit really fits: proven against a node parked far outside, and against the fact that the
// graph is then wider than the canvas – otherwise centring alone would pass this check.
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`,
@@ -553,7 +553,7 @@ export async function graph(t) {
await sleep(250);
check('the parked node is back where it started', (await positions()) === before13i, await positions());
- // 13o. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
+ // 6p. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
// comes back while either of its two nodes is selected.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
@@ -628,7 +628,7 @@ export async function graph(t) {
JSON.stringify(shown),
);
- // 13p. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on
+ // 6q. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on
// the very same pixels – the names smearing over each other. One port, one mark.
const before13p = await graphCounts();
const forkPort = await json(
@@ -770,7 +770,7 @@ export async function graph(t) {
JSON.stringify({ offered: Boolean(offered), cut, after: await forkedNow() }),
);
- // 13q. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
+ // 6r. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
// link lands on it until it is expanded, and the state is saved with the project.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
diff --git a/tools/e2e/07-parenting.mjs b/tools/e2e/07-parenting.mjs
index 28443e5f..dacb30ac 100644
--- a/tools/e2e/07-parenting.mjs
+++ b/tools/e2e/07-parenting.mjs
@@ -25,7 +25,7 @@ export async function parenting(t) {
stop,
} = t;
- // 14. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
+ // 7a. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
// position (local becomes the difference) and is listed right below its parent.
await mutate(`
const objs = __gm.state.activeScene.objects;
@@ -53,7 +53,7 @@ export async function parenting(t) {
);
await shot('20-parented');
- // 14a. Moving the parent (X field of the red crate) drags the child along in world space.
+ // 7b. Moving the parent (X field of the red crate) drags the child along in world space.
await clickText('Red crate');
const xField = await locate('X');
await click(xField.x + 160, xField.y);
@@ -70,7 +70,7 @@ export async function parenting(t) {
`${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`,
);
- // 14b. Play: entities mirror the hierarchy; undo restores parent and position.
+ // 7c. Play: entities mirror the hierarchy; undo restores parent and position.
await select(null);
await press('Enter', { ctrl: true });
await sleep(300);
@@ -90,7 +90,7 @@ export async function parenting(t) {
JSON.stringify(o2),
);
- // 14c. Parent via the inspector menu: the picker is searchable, Enter takes the single match.
+ // 7d. Parent via the inspector menu: the picker is searchable, Enter takes the single match.
await clickText('Blue crate');
await clickText('None'); // the Parent dropdown, labelled with the current parent
const entries = await json('__gm.overlayTexts()');
@@ -128,7 +128,7 @@ export async function parenting(t) {
JSON.stringify(o2),
);
- // 14d. The gap above a row is a different drop than the row itself: it orders the dragged
+ // 7e. The gap above a row is a different drop than the row itself: it orders the dragged
// object among that row's siblings instead of putting it inside, and moves nothing.
const rowsBefore = await json('__gm.hierarchyOrder()');
check(
diff --git a/tools/e2e/08-colliders.mjs b/tools/e2e/08-colliders.mjs
index 3ec788f7..bca6daac 100644
--- a/tools/e2e/08-colliders.mjs
+++ b/tools/e2e/08-colliders.mjs
@@ -26,7 +26,7 @@ export async function colliders(t) {
stop,
} = t;
- // 15. Right-click the red crate → "Add collider": a collider asset sized like the crate
+ // 8a. Right-click the red crate → "Add collider": a collider asset sized like the crate
// (100×60) is placed as its child at the centre, selected and drawn in the overlay. The
// seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one is
// undone again to keep exactly one collider per body for the checks below.
@@ -59,7 +59,7 @@ export async function colliders(t) {
(await objects()).length === objectsBefore && colliderAssetGone,
);
- // 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
+ // 8b. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
// the crates overlapping: the log names the blue crate exactly once (enter, not stay),
// and colliders are not rendered in play mode.
await mutate(`
@@ -89,7 +89,7 @@ export async function colliders(t) {
);
await stop();
- // 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
+ // 8c. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
// blue collider 140 wide → 30 of free travel) and hold ArrowRight: the Mover's Move
// (Collide with solids) parks the red crate flush against the blue one at world x = −120.
// Making the blue collider a trigger lets it pass through instead (drawn amber). A frame
diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs
index 25e3ebda..a68c3567 100644
--- a/tools/e2e/09-containers.mjs
+++ b/tools/e2e/09-containers.mjs
@@ -55,7 +55,7 @@ export async function containers(t) {
await sleep(250);
};
- // 16. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy:
+ // 9a. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy:
// both are selected, the inspector shows the multi view.
await mutate(`
const objs = __gm.state.activeScene.objects;
@@ -85,7 +85,7 @@ export async function containers(t) {
JSON.stringify(s.selection),
);
- // 16a. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
+ // 9b. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
// the objects whose position lies inside – the crate and its collider, not the Mover
// (at y = 110), not the camera (at the origin).
await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
@@ -97,7 +97,7 @@ export async function containers(t) {
);
await shot('25-marquee');
- // 16b. Dragging one of several selected objects moves all of them as one undo step.
+ // 9c. Dragging one of several selected objects moves all of them as one undo step.
const redScreen = await json(`__gm.objectScreen('o1')`);
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
@@ -112,7 +112,7 @@ export async function containers(t) {
(await object('o1')).position.x === -150 && (await object('o2')).position.x === 120,
);
- // 16c. Delete with a multi-selection removes all of them (with subtrees) as one step.
+ // 9d. Delete with a multi-selection removes all of them (with subtrees) as one step.
const listedBeforeDelete = (await objects()).length;
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await press('Delete');
@@ -124,7 +124,7 @@ export async function containers(t) {
await undo();
check('undo restores all of them', (await objects()).length === listedBeforeDelete);
- // 16d. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
+ // 9e. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
// container object stands in their stead and the five are resolved from its template.
const assetsBefore = (await json('__gm.state.project.assets')).length;
const storedBefore = (await storedObjects()).length;
@@ -201,7 +201,7 @@ export async function containers(t) {
);
await shot('24-container-created');
- // 16e. Dragging the container symbol in the viewport moves the whole group; undo restores it.
+ // 9f. Dragging the container symbol in the viewport moves the whole group; undo restores it.
const cScreen = await json(`__gm.objectScreen('${instanceA.id}')`);
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
check(
@@ -212,7 +212,7 @@ export async function containers(t) {
await undo();
check('undo moves the container back', (await object(instanceA.id)).position.x === -15);
- // 16f. Placing the container asset again adds exactly ONE stored object – and five more that
+ // 9g. Placing the container asset again adds exactly ONE stored object – and five more that
// the editor lists. Against the old copying model the stored count would jump by six.
const spot = await json('__gm.viewportCenter()');
await dragAsset(containerAsset.id, spot.x + 90, spot.y + 60);
@@ -243,7 +243,7 @@ export async function containers(t) {
);
await shot('26-container-placed');
- // 16g. A value edited on one instance is kept on that instance and nowhere else: the scene
+ // 9h. A value edited on one instance is kept on that instance and nowhere else: the scene
// grows by no object, the deviation lands in `instanceEdits` under the template path.
await selectObject(redB);
check('the inspector names the container the object comes from', await has(containerAsset.name));
@@ -262,7 +262,7 @@ export async function containers(t) {
JSON.stringify((await object(redA)).position),
);
- // 16h. Editing the template reaches every instance that has not been touched there – and only
+ // 9i. Editing the template reaches every instance that has not been touched there – and only
// those. The untouched axis of the edited object follows along with the rest.
const setTemplate = (body) =>
mutate(
@@ -294,7 +294,7 @@ export async function containers(t) {
(await object(redA)).position.x === -135 && (await object(redA)).name === 'Red crate',
);
- // 16i. Structure is the template's business: resolved content cannot be removed, re-parented,
+ // 9j. Structure is the template's business: resolved content cannot be removed, re-parented,
// given a collider or turned into a container of its own.
// The hierarchy menu is the only way to a collider, and a disabled entry must not even reach
// the command: the command refuses too, but with a notice.
@@ -385,7 +385,7 @@ export async function containers(t) {
);
await shot('26b-structure-refused');
- // 16j. Unpack: the content is written into the scene as ordinary objects under the very same
+ // 9k. Unpack: the content is written into the scene as ordinary objects under the very same
// ids, the instance stops following the template – and now its structure *can* be changed.
await dragAsset(containerAsset.id, spot.x - 90, spot.y + 60);
const instanceC = (await storedObjects()).find(
@@ -465,7 +465,7 @@ export async function containers(t) {
JSON.stringify({ now: (await storedObjects()).length, was: storedBeforeUnpack }),
);
- // 16k. Spawning a container at runtime spells it out the same way Unpack does, under the same
+ // 9l. Spawning a container at runtime spells it out the same way Unpack does, under the same
// derived ids – and Stop throws the whole run away with it.
const spawnParam = (assetId) =>
mutate(
@@ -495,7 +495,7 @@ export async function containers(t) {
check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length);
await spawnParam('crate');
- // 16l. Apply / revert on resolved content. The red crate of instance B was moved on x, the one
+ // 9m. Apply / revert on resolved content. The red crate of instance B was moved on x, the one
// of instance A still matches the template; ↑ writes the value into the template and A
// follows, ↺ drops the deviation so B follows again.
const templateRed = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === redAPath);
@@ -528,7 +528,7 @@ export async function containers(t) {
JSON.stringify(revertedB.instanceEdits),
);
- // 16m. An override on resolved content deviates from two bases at once – the template it
+ // 9n. An override on resolved content deviates from two bases at once – the template it
// follows and, one step further up, the asset – so apply offers a menu. Reset never goes
// past the nearest base, so it stays a single button.
await selectObject(redB);
@@ -574,7 +574,7 @@ export async function containers(t) {
JSON.stringify({ template: (await templateRed()).overrides, a: (await object(redA)).overrides }),
);
- // 16n. redB matches its template again (both 77) and has nothing of its own left to reset –
+ // 9o. redB matches its template again (both 77) and has nothing of its own left to reset –
// but the value still differs from the asset (100), which apply can still reach past it.
await sleep(200);
check(
@@ -593,7 +593,7 @@ export async function containers(t) {
.objects.find(o => o.id === '${redAPath}').overrides.width`,
);
- // 16o. Save and load: an instance is a container object plus its deviations, nothing else.
+ // 9p. Save and load: an instance is a container object plus its deviations, nothing else.
// The content must be nowhere in the saved scene, and reloading must resolve it back.
await selectObject(redB);
await setField('Y', 12);
diff --git a/tools/e2e/10-text-and-enabled.mjs b/tools/e2e/10-text-and-enabled.mjs
index 5833c814..cdfc131d 100644
--- a/tools/e2e/10-text-and-enabled.mjs
+++ b/tools/e2e/10-text-and-enabled.mjs
@@ -18,7 +18,7 @@ export async function textAndEnabled(t) {
typeText,
} = t;
- // 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
+ // 10a. Text assets: created through the + menu, placed, and rendered as real text in the scene.
await clickText('+');
await clickText('Text');
await sleep(100);
@@ -35,7 +35,7 @@ export async function textAndEnabled(t) {
check('a placed text object draws its content in the scene', await has('Hello world'));
await shot('29-text-object');
- // 17a. The enabled switch: every object has it, switching it off is a normal override, and a
+ // 10b. The enabled switch: every object has it, switching it off is a normal override, and a
// switched-off object disappears from the scene view too (it stays in the hierarchy).
const enabledRow = await json("__gm.inspectorField('Enabled')");
check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow));
@@ -89,7 +89,7 @@ export async function textAndEnabled(t) {
check('and it is still not drawn', !(await json('__gm.drawOrder()')).includes('otext'));
await shot('30b-disabled-gizmo');
- // 17b. In the running game a switched-off object does not exist: it is not rendered, and
+ // 10c. In the running game a switched-off object does not exist: it is not rendered, and
// switching it back on brings it back.
await play();
await sleep(200);
@@ -105,7 +105,7 @@ export async function textAndEnabled(t) {
await stop();
check('stop leaves the play-mode switch behind', (await object('otext')).overrides.enabled === false);
- // 17c. A field that shows no decimals must not store any either. Font size is such a field, so
+ // 10d. A field that shows no decimals must not store any either. Font size is such a field, so
// typing a fraction has to round: showing "33" while the project holds 32.6 would be a lie.
const fontLabel = await locate('Font size');
check('Font size reads as a whole number', Boolean(await has('32')) && !(await has('32.000')));
diff --git a/tools/e2e/11-menus.mjs b/tools/e2e/11-menus.mjs
index bea7673d..3f33c2a4 100644
--- a/tools/e2e/11-menus.mjs
+++ b/tools/e2e/11-menus.mjs
@@ -24,7 +24,7 @@ export async function menus(t) {
__gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
await sleep(150);
- // 18. A picker with 40+ entries is capped in height and stays fully on screen instead of
+ // 11a. A picker with 40+ entries is capped in height and stays fully on screen instead of
// running off the bottom edge.
const parentRow = await json("__gm.inspectorField('Parent')");
await click(parentRow.x, parentRow.y);
@@ -42,7 +42,7 @@ export async function menus(t) {
);
await shot('31-long-parent-menu');
- // 18a. The entries scroll under the wheel; the search box above them does not move.
+ // 11b. The entries scroll under the wheel; the search box above them does not move.
// Anchored on a filler row read off the open menu: "None" could match an inspector field behind it,
// and which fillers are drawn depends on what earlier steps left in the scene.
const visibleFillers = (await json('__gm.overlayTexts()')).filter((e) => e.startsWith('Filler '));
diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs
index 8d42dcf0..846184e5 100644
--- a/tools/e2e/12-textures.mjs
+++ b/tools/e2e/12-textures.mjs
@@ -48,7 +48,7 @@ export async function textures(t) {
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await sleep(200);
- // 19. Pasting an image imports it: uploaded, stored under its hash, and made an asset.
+ // 12a. Pasting an image imports it: uploaded, stored under its hash, and made an asset.
await evaluate(PASTE_IMAGE);
await sleep(2500);
@@ -68,7 +68,7 @@ export async function textures(t) {
JSON.stringify({ w: texture.w, h: texture.h }),
);
- // 19a. Importing the same picture again reuses the asset: the server hands back the file it
+ // 12b. Importing the same picture again reuses the asset: the server hands back the file it
// already has, since the name is the content hash.
await evaluate(PASTE_IMAGE);
await sleep(2500);
@@ -86,7 +86,7 @@ export async function textures(t) {
JSON.stringify(await json('__gm.state.notice')),
);
- // 19b. The picture is actually drawn – a sprite wearing it is no longer one flat colour.
+ // 12c. The picture is actually drawn – a sprite wearing it is no longer one flat colour.
// On a fresh white sprite of its own, so the sample does not depend on eleven steps of edits
// to the fixture. Cloned rather than written as a literal: a literal missed `editorOffset`,
// which sent the position to NaN and culled the object without an error.
@@ -188,7 +188,7 @@ export async function textures(t) {
await mutate(enlarge(160, 120));
await sleep(400);
- // 19c. The colour multiplies the picture, so a tinted white quadrant stops being white.
+ // 12d. The colour multiplies the picture, so a tinted white quadrant stops being white.
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0x3366ff");
await sleep(600);
const tinted = await at(0.3, 0.3);
@@ -198,7 +198,7 @@ export async function textures(t) {
JSON.stringify({ was: bottomRight, tinted }),
);
- // 19d. A reference that no longer resolves falls back to the plain colour instead of failing.
+ // 12e. A reference that no longer resolves falls back to the plain colour instead of failing.
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.texture = 'gone'");
await sleep(600);
const fallback = await at(0.3, 0.3);
@@ -209,7 +209,7 @@ export async function textures(t) {
);
}
- // 19e. "+ → Texture" offers the pictures the server already holds, unreferenced ones included:
+ // 12f. "+ → Texture" offers the pictures the server already holds, unreferenced ones included:
// paste a second picture, undo the asset (the file stays), then pick that file in the dialog.
await evaluate(PASTE_SPARE);
await sleep(2500);
@@ -315,7 +315,7 @@ export async function textures(t) {
(await json("__gm.state.project.assets.filter((a) => a.type === 'texture').length")) === 1,
);
- // 19f. Right-clicking a texture asset makes a sprite wearing it at the picture's pixel size.
+ // 12g. Right-clicking a texture asset makes a sprite wearing it at the picture's pixel size.
const entryText = 'Create sprite from texture';
const spriteRow = await json("__gm.assetRow('crate')");
await click(spriteRow.x, spriteRow.y, 'right');
diff --git a/tools/e2e/13-frames.mjs b/tools/e2e/13-frames.mjs
index a6063b42..fc3fbdf6 100644
--- a/tools/e2e/13-frames.mjs
+++ b/tools/e2e/13-frames.mjs
@@ -36,7 +36,7 @@ function nearestColour(sample) {
export async function frames(t) {
const { check, sleep, json, evaluate, shot, clickText, drag, press, undo } = t;
- // 20. The tab exists and shows the texture the selection points at.
+ // 13a. The tab exists and shows the texture the selection points at.
await press('Escape');
await evaluate('__gm.state.select(null)');
const texture = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
@@ -51,7 +51,7 @@ export async function frames(t) {
);
await shot('33-texture-tab');
- // 21. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24),
+ // 13b. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24),
// inset by a pixel: a press on a frame edge is read as a resize, not a new rectangle.
const from = await json('__gm.texture.pointAt(33, 1)');
const to = await json('__gm.texture.pointAt(63, 23)');
@@ -81,7 +81,7 @@ export async function frames(t) {
);
await shot('34-frame-cut');
- // 21a. It is one undo step, not one per pointer move.
+ // 13c. It is one undo step, not one per pointer move.
await undo();
await sleep(400);
check(
@@ -93,7 +93,7 @@ export async function frames(t) {
await t.redo();
await sleep(400);
- // 22. A sprite wearing the frame shows that part of the sheet and nothing else.
+ // 13d. A sprite wearing the frame shows that part of the sheet and nothing else.
await evaluate(`(() => {
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.color = 0xffffff;
@@ -120,7 +120,7 @@ export async function frames(t) {
await shot('35-sprite-wearing-a-frame');
}
- // 23. Slicing offers a live count before it commits.
+ // 13e. Slicing offers a live count before it commits.
// Repainted magenta first: magenta is nowhere in the sheet, so the fallback check below
// cannot be confused with a part of the picture.
await evaluate(`(() => {
@@ -156,7 +156,7 @@ export async function frames(t) {
check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0]));
await shot('36-sliced-sheet');
- // 23a. A sprite pointed at a frame slicing removed falls back to its colour.
+ // 13f. A sprite pointed at a frame slicing removed falls back to its colour.
await evaluate("__gm.showTab('scene')");
await sleep(700);
if (box) {
diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs
index c1a9478c..a53d8d1a 100644
--- a/tools/e2e/14-overflow.mjs
+++ b/tools/e2e/14-overflow.mjs
@@ -28,7 +28,7 @@ export async function overflow(t) {
__gm.state.select(null)`);
await sleep(600);
- // 21. The pickers keep the width they were given instead of growing with the name they show.
+ // 14a. The pickers keep the width they were given instead of growing with the name they show.
const after = await pickerWidths();
for (const tab of ['graph', 'texture', 'container']) {
check(
@@ -38,7 +38,7 @@ export async function overflow(t) {
);
}
- // 21a. Every tab, because each one owns a different set of controls.
+ // 14b. Every tab, because each one owns a different set of controls.
for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400);
@@ -50,7 +50,7 @@ export async function overflow(t) {
);
}
- // 21b. Shortened, not simply dropped: the value must still be readable and marked as cut.
+ // 14c. Shortened, not simply dropped: the value must still be readable and marked as cut.
await evaluate("__gm.showTab('graph')");
await sleep(400);
const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement'));
@@ -61,7 +61,7 @@ export async function overflow(t) {
);
await shot('36-long-labels');
- // 21c. An inspector full of dropdowns – the parent row and the enum fields – with a selection
+ // 14d. An inspector full of dropdowns – the parent row and the enum fields – with a selection
// whose asset name is now overlong too.
await evaluate("__gm.showTab('scene')");
await sleep(300);
@@ -70,7 +70,7 @@ export async function overflow(t) {
const inspector = await json('__gm.textOverflows()');
check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector));
- // 21d. The other direction: a control narrow enough to swallow its whole label draws nothing
+ // 14e. The other direction: a control narrow enough to swallow its whole label draws nothing
// at all, which the overflow walk cannot see – an empty text spills over no edge.
for (const tab of ['scene', 'graph', 'texture', 'container', 'list']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
diff --git a/tools/e2e/15-aspect.mjs b/tools/e2e/15-aspect.mjs
index 05cb69e2..13d3e522 100644
--- a/tools/e2e/15-aspect.mjs
+++ b/tools/e2e/15-aspect.mjs
@@ -24,7 +24,7 @@ export async function aspect(t) {
const sheet = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
check('the imported sheet is still there', sheet !== '', sheet);
- // 26. A sprite wearing nothing has nothing to be proportional to, so the buttons are absent.
+ // 15a. A sprite wearing nothing has nothing to be proportional to, so the buttons are absent.
// Edited as an asset: one base value per row instead of an override, so the numbers below
// say what they mean.
await mutate(`
@@ -38,7 +38,7 @@ export async function aspect(t) {
check('no texture, no aspect buttons', (await sizeButton('↔')) === '' && (await sizeButton('↕')) === '');
await shot('40-aspect-without-a-texture');
- // 27. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size,
+ // 15b. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size,
// so both rows offer two things and show the menu form.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await sleep(500);
@@ -48,7 +48,7 @@ export async function aspect(t) {
);
await shot('41-aspect-buttons');
- // 28. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
+ // 15c. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
await clickText('↔ ▾');
await sleep(400);
await clickText('Fit to 64 × 48');
@@ -65,7 +65,7 @@ export async function aspect(t) {
);
await shot('42-aspect-fitted');
- // 28a. One command, so it is one undo step – and the menu form comes back with the old shape.
+ // 15d. One command, so it is one undo step – and the menu form comes back with the old shape.
await undo();
await sleep(500);
check('undo takes the width back', (await json(spriteProp('width'))) === 100);
@@ -73,7 +73,7 @@ export async function aspect(t) {
await t.redo();
await sleep(500);
- // 28b. What is left over is the pixel size, now a direct button: it writes *both* sides, and
+ // 15e. What is left over is the pixel size, now a direct button: it writes *both* sides, and
// the whole button goes because there is nothing left to offer.
await clickText('↔');
await sleep(500);
@@ -96,7 +96,7 @@ export async function aspect(t) {
await t.redo();
await sleep(500);
- // 29. A frame is the picture the sprite wears, not the sheet it was cut from: 40 x 10 against
+ // 15f. A frame is the picture the sprite wears, not the sheet it was cut from: 40 x 10 against
// the sheet's 64 x 48, so the two ratios cannot be confused for one another.
await mutate(`
const asset = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet)});
@@ -118,7 +118,7 @@ export async function aspect(t) {
);
check('and only the pixel size is left to offer', (await sizeButton('↕')) === 'plain');
- // 30. The same rows on a placed object write an override rather than the asset value.
+ // 15g. The same rows on a placed object write an override rather than the asset value.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await select({ kind: 'object', holderId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
await sleep(600);
@@ -140,7 +140,7 @@ export async function aspect(t) {
);
await shot('44-aspect-on-an-object');
- // 30a. The pixel size on an object writes both overrides, and as one undo step.
+ // 15h. The pixel size on an object writes both overrides, and as one undo step.
await clickText('↔');
await sleep(500);
const sized = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
diff --git a/tools/e2e/16-container.mjs b/tools/e2e/16-container.mjs
index 7528a201..b89b9d74 100644
--- a/tools/e2e/16-container.mjs
+++ b/tools/e2e/16-container.mjs
@@ -37,7 +37,7 @@ export async function containerTab(t) {
);
await sleep(200);
- // 22. The tab opens on the container the project has, and its header says which one.
+ // 16a. The tab opens on the container the project has, and its header says which one.
await clickText('Container');
await sleep(500);
const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
@@ -60,7 +60,7 @@ export async function containerTab(t) {
);
await shot('30-container-tab');
- // 22a. The hierarchy follows the visible tab: it lists the template, not the scene.
+ // 16b. The hierarchy follows the visible tab: it lists the template, not the scene.
const rows = await json('__gm.hierarchyOrder()');
check(
'the hierarchy lists the template objects while the tab is open',
@@ -68,7 +68,7 @@ export async function containerTab(t) {
JSON.stringify(rows),
);
- // 22b. Clicking a template object selects it against the container as its holder – the same
+ // 16c. Clicking a template object selects it against the container as its holder – the same
// id set as the scene would give, but a different holder.
const root = template.find((o) => o.parentId === '');
const at = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
@@ -82,7 +82,7 @@ export async function containerTab(t) {
JSON.stringify(s.selection),
);
- // 22c. The point of the tab: a drag writes the template, and every instance follows it – except
+ // 16d. The point of the tab: a drag writes the template, and every instance follows it – except
// one that was moved there on its own, whose deviation the instance keeps.
const templateX = () =>
json(
@@ -132,7 +132,7 @@ export async function containerTab(t) {
);
await setDeviation(`delete edits['${root.id}']`);
- // 22d. An asset dragged from the panel lands in the view it is dropped on: dropping on this
+ // 16e. An asset dragged from the panel lands in the view it is dropped on: dropping on this
// tab adds one object to the template, not to the scene.
// What the scene *stores* – that must not move. What it *lists* has to grow by one per placed
// instance instead, since every one of them resolves the new template object too.
@@ -158,7 +158,7 @@ export async function containerTab(t) {
);
await shot('31-container-object-added');
- // 22e. Delete works on a template object like on any other, as one undo step.
+ // 16f. Delete works on a template object like on any other, as one undo step.
await press('Delete');
await sleep(250);
check(
@@ -174,7 +174,7 @@ export async function containerTab(t) {
await press('Delete');
await sleep(250);
- // 22f. Fit frames the content. Proven against an object parked far outside the view: without
+ // 16g. Fit frames the content. Proven against an object parked far outside the view: without
// fitting it is nowhere near the canvas.
await evaluate(
`(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = 3000; __gm.state.preview(); })()`,
@@ -198,7 +198,7 @@ export async function containerTab(t) {
await sleep(300);
await shot('32-container-fitted');
- // 22g. A template object exists only in this tab, so selecting one from elsewhere switches to
+ // 16h. A template object exists only in this tab, so selecting one from elsewhere switches to
// it – the rule a graph node and a texture frame already follow. A scene object does not.
await evaluate("__gm.showTab('scene')");
await sleep(300);
@@ -221,7 +221,7 @@ export async function containerTab(t) {
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
- // 22h. Switching to another container is a plain asset selection – the same thing the header's
+ // 16i. Switching to another container is a plain asset selection – the same thing the header's
// picker does. The hierarchy has to follow it, title and rows both.
await evaluate("__gm.showTab('container')");
await sleep(300);
@@ -286,7 +286,7 @@ export async function containerTab(t) {
JSON.stringify({ before: ownBefore.length, after: ownAfter.length }),
);
- // 22i. Nesting: another container, though, may go in – and the tab it lands in stays put.
+ // 16j. Nesting: another container, though, may go in – and the tab it lands in stays put.
const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
await dragAsset(containerId, canvas2.x - 40, canvas2.y + 40);
const nestedAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
@@ -298,7 +298,7 @@ export async function containerTab(t) {
JSON.stringify({ before: nested.length, after: nestedAfter.length }),
);
- // 22j. Nesting resolves all the way down: placing the outer container in the scene stores one
+ // 16k. Nesting resolves all the way down: placing the outer container in the scene stores one
// object, and the inner container's content appears under `~/`.
const innerObject = nestedAfter.find((o) => o.assetId === containerId);
const innerTemplate = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
diff --git a/tools/e2e/17-editor-run.mjs b/tools/e2e/17-editor-run.mjs
index b138fff4..863945d0 100644
--- a/tools/e2e/17-editor-run.mjs
+++ b/tools/e2e/17-editor-run.mjs
@@ -45,7 +45,7 @@ const driftGraph = {
export async function editorRun(t) {
const { check, sleep, evaluate, json, shot, click, holdKey, mutate, undo, object, api, projectId } = t;
- // 18a. A box with a drifting behavior below it. Written into the model directly: what is under
+ // 17a. A box with a drifting behavior below it. Written into the model directly: what is under
// test is the switch, not another way of building a graph.
await mutate(`
const p = __gm.state.project;
@@ -74,7 +74,7 @@ export async function editorRun(t) {
`${JSON.stringify(idle)} x=${startX}`,
);
- // 18b. The switch itself, through the inspector. It belongs with Enabled and above the
+ // 17b. The switch itself, through the inspector. It belongs with Enabled and above the
// VARIABLES section: it is a switch of the behavior, not something the behavior declares.
await evaluate(
`__gm.state.select({ kind: 'object', holderId: __gm.state.project.activeSceneId, objectId: 'er-logic' })`,
@@ -110,7 +110,7 @@ export async function editorRun(t) {
`${JSON.stringify(running)} mode=${mode}`,
);
- // 18c. It really drives its owner, and it is the stored scene object that moves – no runtime
+ // 17c. It really drives its owner, and it is the stored scene object that moves – no runtime
// copy is involved here, which is the whole difference to playing.
const before = await boxX();
await sleep(600);
@@ -122,7 +122,7 @@ export async function editorRun(t) {
);
await shot('36-editor-run');
- // 18d. The point of this semantic: the change is a real edit, so it reaches the server.
+ // 17d. The point of this semantic: the change is a real edit, so it reaches the server.
const first = await api(`projects/${projectId}`);
await sleep(2000);
const second = await api(`projects/${projectId}`);
@@ -142,7 +142,7 @@ export async function editorRun(t) {
`${saves} saves in 2 s`,
);
- // 18e. Taking the switch back stops it again.
+ // 17e. Taking the switch back stops it again.
await click(runBox.x, runBox.y);
await sleep(300);
const stoppedAt = await boxX();
@@ -154,7 +154,7 @@ export async function editorRun(t) {
`${JSON.stringify(stopped)} x=${stoppedAt}`,
);
- // 18f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
+ // 17f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
// undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock.
await undo();
await undo();
@@ -168,7 +168,7 @@ export async function editorRun(t) {
`overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`,
);
- // 18g. The case a real project has: the behavior sits inside a *placed container* – a level
+ // 17g. The case a real project has: the behavior sits inside a *placed container* – a level
// packed up once and dropped into the scene. The scene stores the container object alone,
// so this only works if the run resolves the template instead of expecting stored objects.
await mutate(`
@@ -213,7 +213,7 @@ export async function editorRun(t) {
`stored=${JSON.stringify(stored.filter((id) => id.startsWith('er-')))} edit.x=${edits['t-box']?.position?.x} template.x=${template.x}`,
);
- // 18h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the
+ // 17h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the
// arrow keys walking the hierarchy must not steer a game at the same time. The game tab is
// where no key means anything to the editor, so that is where a behavior may have them.
await mutate(`
diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs
index e6e9e4db..5c4f6a37 100644
--- a/tools/e2e/18-pause.mjs
+++ b/tools/e2e/18-pause.mjs
@@ -51,7 +51,7 @@ export async function pause(t) {
const boxX = async () => (await entity('p-box'))?.x ?? NaN;
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
- // 19a. A box with a drifting behavior below it, written into the model directly: what is under
+ // 18a. A box with a drifting behavior below it, written into the model directly: what is under
// test is the pause, not another way of building a graph.
const rig = await scaffoldBehavior(t, {
assetId: 'pauser',
@@ -75,7 +75,7 @@ export async function pause(t) {
`x ${beforeDrift} → ${drifted}, ${JSON.stringify(running)}`,
);
- // 19b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps
+ // 18b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps
// ticking while the picture happens to look the same would pass a position check alone.
await clickText('❚❚ Pause');
await sleep(200);
@@ -100,7 +100,7 @@ export async function pause(t) {
);
await shot('36-paused');
- // 19c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for
+ // 18c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for
// each of them. The count before proves the graph reacts at all.
const hitsBefore = await keyHits();
await press('ArrowRight');
@@ -110,7 +110,7 @@ export async function pause(t) {
await press('ArrowRight');
await sleep(200);
- // 19d. Ctrl+Enter resumes, the same shortcut that started play.
+ // 18d. Ctrl+Enter resumes, the same shortcut that started play.
await press('Enter', { ctrl: true });
await sleep(400);
const resumed = await playInfo();
@@ -139,7 +139,7 @@ export async function pause(t) {
`${hitsBefore} → ${await keyHits()}`,
);
- // 19e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts
+ // 18e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts
// moving. A pause left standing would leave the editor unable to run anything.
await press('Enter', { ctrl: true });
await sleep(200);
@@ -159,7 +159,7 @@ export async function pause(t) {
`after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`,
);
- // 19f. The graph stays editable while the game runs, with the mouse as in edit mode: a click
+ // 18f. The graph stays editable while the game runs, with the mouse as in edit mode: a click
// selects the Move node, Delete removes it, and the running world stops drifting the box.
await select({ kind: 'asset', assetId: 'pauser' });
await evaluate(`__gm.showTab('graph')`);
@@ -244,7 +244,7 @@ export async function pause(t) {
JSON.stringify((await asset('pauser')).graph.nodes.map((n) => n.id)),
);
- // 19g. Play discarded its copy, so the box is back at the position the model holds.
+ // 18g. Play discarded its copy, so the box is back at the position the model holds.
const left = await rig.remove();
check(
'the step leaves nothing of its own behind',
diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs
index bdaf27cf..22a94bdf 100644
--- a/tools/e2e/21-list.mjs
+++ b/tools/e2e/21-list.mjs
@@ -33,7 +33,7 @@ export async function listTab(t) {
`);
await sleep(150);
- // 1. The inspector shows the count, not a comma-separated line.
+ // 21a. The inspector shows the count, not a comma-separated line.
const button = await find('3 items');
check('a list property reads as its item count', button !== null, JSON.stringify(button));
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
@@ -48,7 +48,7 @@ export async function listTab(t) {
check('and the inspector is still beside it', (await find('VARIABLES')) !== null);
await shot('02-list-tab');
- // 3. A row edits its item, and it is one undo step.
+ // 21b. A row edits its item, and it is one undo step.
const second = await json('__gm.list.fieldAt(1)');
await click(second.x, second.y);
await typeText('42');
@@ -60,7 +60,7 @@ export async function listTab(t) {
info = await json('__gm.list.info()');
check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]);
- // 4. A short list opened after a long one. Its rows leave the content height pinned to the
+ // 21c. A short list opened after a long one. Its rows leave the content height pinned to the
// viewport, so a layout skipped on "same size" would stack them all at the top.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'listy');
@@ -83,7 +83,7 @@ export async function listTab(t) {
JSON.stringify(shortRows),
);
- // 5. `+ Item` asks for a kind, since the list does not hold one for it.
+ // 21d. `+ Item` asks for a kind, since the list does not hold one for it.
await clickText('+ Item');
await sleep(150);
check(
@@ -104,7 +104,7 @@ export async function listTab(t) {
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3);
await shot('03-rows');
- // 6. Back on the scene tab the button carries the current count.
+ // 21e. Back on the scene tab the button carries the current count.
await mutate('__gm.state.openList(null)');
await sleep(200);
check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene');
@@ -115,7 +115,7 @@ export async function listTab(t) {
await sleep(200);
check('and the count follows the value', (await find('2 items')) !== null);
- // 6b. A list declares nothing about its items, so the kind sits on the row: the mark in front of
+ // 21f. A list declares nothing about its items, so the kind sits on the row: the mark in front of
// it is the picker, and picking changes that one item and no other.
const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
@@ -146,7 +146,7 @@ export async function listTab(t) {
JSON.stringify(listy.props.nums),
);
- // 6c. A colour is the one kind a stored number cannot say for itself, so it is tagged.
+ // 21g. A colour is the one kind a stored number cannot say for itself, so it is tagged.
mark = await json('__gm.list.markAt(1)');
await click(mark.x, mark.y);
await sleep(150);
@@ -167,7 +167,7 @@ export async function listTab(t) {
await undo();
await sleep(200);
- // 6d. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
+ // 21h. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
// The row carries the picker the `texture` property has, frames of a sheet included.
// The pictures the earlier steps imported are set aside for this one: the menu lists every sheet
// with all of its frames, and a long one would put the entry to click outside the drawn menu.
@@ -241,7 +241,7 @@ export async function listTab(t) {
JSON.stringify(lefts),
);
- // 7. A list whose target is gone empties the tab rather than showing a stale one.
+ // 21i. A list whose target is gone empties the tab rather than showing a stale one.
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
await sleep(200);
check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list');
@@ -256,7 +256,7 @@ export async function listTab(t) {
check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title);
await shot('04-empty');
- // 8. A list whose items are structs: each row is the bundle's fields under each other, built by
+ // 21j. A list whose items are structs: each row is the bundle's fields under each other, built by
// the inspector's own struct renderer. Values are two digits so no row's index can match one.
await mutate(`
const p = __gm.state.project;
@@ -317,7 +317,7 @@ export async function listTab(t) {
JSON.stringify(back),
);
- // 8b. A struct item carries its own shape, so it is named, typed and grown on its own row – and
+ // 21k. A struct item carries its own shape, so it is named, typed and grown on its own row – and
// primitives only, because a list inside a list item is the one shape the model does not store.
const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check('a struct item carries the add-a-field control', (await find('+ Field')) !== null);
@@ -387,7 +387,7 @@ export async function listTab(t) {
JSON.stringify(await cellsOf()),
);
- // 9. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
+ // 21l. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
// button too.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'celly');
diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs
index b3f1a0d4..09cca3fa 100644
--- a/tools/e2e/22-struct.mjs
+++ b/tools/e2e/22-struct.mjs
@@ -44,7 +44,7 @@ export async function structVariable(t) {
`);
await sleep(150);
- // 1. The declaration is what the inspector draws: a head that can add fields, a row per field.
+ // 22a. The declaration is what the inspector draws: a head that can add fields, a row per field.
check('a struct variable carries the add-a-field control', (await find('+ Field')) !== null);
check('and a row for each declared field', (await find('x')) !== null && (await find('tag')) !== null);
check('whose value is shown, not a summary of the bundle', (await find('30.000')) !== null);
@@ -56,7 +56,7 @@ export async function structVariable(t) {
);
await shot('01-struct-rows');
- // 2. Writing one field keeps the others. Two edits in a row without a rebuild in between is
+ // 22b. Writing one field keeps the others. Two edits in a row without a rebuild in between is
// exactly where handlers sharing one snapshot of the bundle would revive the first value.
const xField = await json("__gm.inspectorField('x')");
await click(xField.x, xField.y);
@@ -77,13 +77,13 @@ export async function structVariable(t) {
JSON.stringify(await piece()),
);
- // 3. One undo step per field.
+ // 22c. One undo step per field.
await undo();
check('undo takes back just the last field', (await piece()).tag === 'tee' && (await piece()).x === 42);
await undo();
check('and the one before it', (await piece()).x === 30, JSON.stringify(await piece()));
- // 4. Adding a field reaches the declaration and the value at once.
+ // 22d. Adding a field reaches the declaration and the value at once.
await clickText('+ Field');
await sleep(150);
await pickKind('Boolean');
@@ -96,7 +96,7 @@ export async function structVariable(t) {
await sleep(200);
check('undo takes it out of the value too', !('field1' in (await piece())), JSON.stringify(await piece()));
- // 5. Renaming a field carries its value along.
+ // 22e. Renaming a field carries its value along.
const nameField = await json("__gm.locate('x')");
await click(nameField.x, nameField.y);
await press('a', { ctrl: true });
@@ -109,7 +109,7 @@ export async function structVariable(t) {
JSON.stringify(await piece()),
);
- // 6. A field may itself be a list. Its row is the same "n items" button a list property has,
+ // 22f. A field may itself be a list. Its row is the same "n items" button a list property has,
// and it opens the List tab one level down – on the field, not on the whole bundle.
await clickText('+ Field');
await sleep(150);
@@ -160,7 +160,7 @@ export async function structVariable(t) {
await mutate('__gm.state.openList(null)');
await sleep(200);
- // 7. The same bundle on a placed object. An override is sparse – it starts out unset – while a
+ // 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a
// field edit rebuilds the whole bundle, so the untouched fields have to come from what the row
// shows (the asset's values) rather than from the empty form of the declaration.
const placed = () => json("__gm.state.activeScene.objects.find((o) => o.id === 'ostruct').overrides.piece");
@@ -180,7 +180,7 @@ export async function structVariable(t) {
await sleep(200);
check('a placed behavior shows its bundle, filled from the asset', (await find('tee')) !== null);
- // 8. It reads exactly like the asset does: a head carrying the struct mark, then one row per
+ // 22h. It reads exactly like the asset does: a head carrying the struct mark, then one row per
// field with its kind mark, its name and its value. What is missing is the shape editing –
// a placed object overrides values, the declaration stays with the asset that owns it.
const bundleMark = await find('⊞');
diff --git a/tools/lib/browser.mjs b/tools/lib/browser.mjs
index 99a1d898..fbdb4200 100644
--- a/tools/lib/browser.mjs
+++ b/tools/lib/browser.mjs
@@ -1,14 +1,4 @@
-/**
- * Launches the check browser locally, or with `GM_REMOTE=user@host` on another machine with the CDP port
- * forwarded over SSH, since this 8 GB host has little memory to spare.
- */
-import { spawn, execFileSync } from 'node:child_process';
-import { fileURLToPath } from 'node:url';
-import { dirname, join } from 'node:path';
-import { homedir } from 'node:os';
-
-const HERE = dirname(fileURLToPath(import.meta.url));
-const LAUNCHER = join(HERE, '..', 'remote', 'gm-browser.ps1');
+import { spawn } from 'node:child_process';
const FLAGS = [
'--headless',
@@ -29,89 +19,19 @@ const FLAGS = [
'--mute-audio',
];
-function sshArgs(key) {
- return [
- '-i',
- key,
- '-o',
- 'IdentitiesOnly=yes',
- '-o',
- 'BatchMode=yes',
- '-o',
- 'StrictHostKeyChecking=accept-new',
- '-o',
- 'ConnectTimeout=15',
- // A dead peer must surface as a closed socket, not as a run that waits forever.
- '-o',
- 'ServerAliveInterval=10',
- '-o',
- 'ServerAliveCountMax=3',
- ];
-}
-
-/**
- * Starts the browser and returns its process plus a `stop()` that ends it the way that host
- * needs. `proc.stderr` carries the browser's own last words either way.
- */
+/** Starts chromium with the CDP port open; `proc.stderr` carries its last words. */
export function launchBrowser({ port, width, height }) {
- const remote = process.env.GM_REMOTE;
- if (!remote) {
- const proc = spawn(
- 'chromium',
- // --no-sandbox: this container runs as root, where chromium refuses to start otherwise.
- [
- ...FLAGS,
- '--no-sandbox',
- `--remote-debugging-port=${port}`,
- `--window-size=${width},${height}`,
- 'about:blank',
- ],
- { stdio: ['ignore', 'ignore', 'pipe'] },
- );
- return { proc, stop: () => proc.kill('SIGTERM'), where: 'local chromium' };
- }
-
- const key = process.env.GM_REMOTE_KEY ?? join(homedir(), '.ssh', 'gm-win.key');
- // Copied every run so the remote never runs a stale launcher.
- execFileSync('scp', [...sshArgs(key), LAUNCHER, `${remote}:gm-browser.ps1`], { stdio: 'pipe' });
-
const proc = spawn(
- 'ssh',
+ 'chromium',
+ // --no-sandbox: this container runs as root, where chromium refuses to start otherwise.
[
- ...sshArgs(key),
- '-o',
- 'ExitOnForwardFailure=yes',
- '-L',
- `${port}:127.0.0.1:${port}`,
- remote,
- `powershell -NoProfile -ExecutionPolicy Bypass -File gm-browser.ps1 -Port ${port} -Size ${width},${height}`,
+ ...FLAGS,
+ '--no-sandbox',
+ `--remote-debugging-port=${port}`,
+ `--window-size=${width},${height}`,
+ 'about:blank',
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
);
-
- return {
- proc,
- /**
- * Ends the browser now rather than after the launcher's idle window. Kill only this pid: the host is
- * someone's desktop, and killing by name would close their browser.
- */
- stop: () => {
- const pidFile = `$env:TEMP\\gm-browser-${port}.pid`;
- try {
- execFileSync(
- 'ssh',
- [
- ...sshArgs(key),
- remote,
- `powershell -NoProfile -Command "if (Test-Path ${pidFile}) { Stop-Process -Id (Get-Content ${pidFile}) -Force -ErrorAction SilentlyContinue }"`,
- ],
- { stdio: 'pipe', timeout: 20_000 },
- );
- } catch {
- // Unreachable host, denied, already gone: the idle window still collects it.
- }
- proc.kill('SIGTERM');
- },
- where: `remote browser on ${remote}`,
- };
+ return { proc, stop: () => proc.kill('SIGTERM'), where: 'local chromium' };
}
diff --git a/tools/lib/builders.mjs b/tools/lib/builders.mjs
index ba8f0623..741c3b38 100644
--- a/tools/lib/builders.mjs
+++ b/tools/lib/builders.mjs
@@ -66,7 +66,6 @@ export const variable = (key, kind, value, fields) => ({
...(fields ? { fields } : {}),
});
-/** A struct field declaration. */
export const field = (key, kind = 'number') => ({ key, kind });
/**
diff --git a/tools/remote/gm-browser.ps1 b/tools/remote/gm-browser.ps1
deleted file mode 100644
index 7c30d46b..00000000
--- a/tools/remote/gm-browser.ps1
+++ /dev/null
@@ -1,120 +0,0 @@
-# Launches a headless browser with the CDP port open and ties its life to whoever is using it.
-#
-# Two measured facts shape this. Windows OpenSSH does not kill its child when the connection
-# drops - waiting for the caller to die leaves the browser running forever (6 orphaned chrome.exe
-# after a SIGKILLed tunnel). And stdin is no signal either: only the first chunk a caller writes
-# ever arrives here, so a heartbeat over stdin looks identical to a dead caller.
-#
-# What is reliable is the CDP port itself: while a run is under way the forwarded connection is
-# established, and it closes the moment the tunnel or the run dies. So the browser stays for as
-# long as somebody is talking to it. The job object then makes the kill total - when this process
-# ends for any reason, KILL_ON_JOB_CLOSE takes the whole browser tree with it.
-param([int]$Port = 9333, [string]$Size = "1280,800", [int]$IdleSeconds = 45, [int]$MaxMinutes = 30)
-$ErrorActionPreference = 'Stop'
-
-Add-Type -TypeDefinition @'
-using System;
-using System.Runtime.InteropServices;
-public static class GmJob {
- [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
- static extern IntPtr CreateJobObject(IntPtr attrs, string name);
- [DllImport("kernel32.dll")]
- static extern bool SetInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint len);
- [DllImport("kernel32.dll")]
- static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
-
- const int ExtendedLimitInformation = 9;
- const int LimitKillOnJobClose = 0x2000;
- // Offset of LimitFlags inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION on x64, and the struct's size.
- const int LimitFlagsOffset = 16;
- const int StructSize = 144;
-
- public static IntPtr Create() {
- IntPtr job = CreateJobObject(IntPtr.Zero, null);
- if (job == IntPtr.Zero) throw new Exception("CreateJobObject failed");
- IntPtr info = Marshal.AllocHGlobal(StructSize);
- try {
- for (int i = 0; i < StructSize; i++) Marshal.WriteByte(info, i, 0);
- Marshal.WriteInt32(info, LimitFlagsOffset, LimitKillOnJobClose);
- if (!SetInformationJobObject(job, ExtendedLimitInformation, info, StructSize))
- throw new Exception("SetInformationJobObject failed: " + Marshal.GetLastWin32Error());
- } finally { Marshal.FreeHGlobal(info); }
- return job;
- }
-
- public static void Assign(IntPtr job, IntPtr process) {
- if (!AssignProcessToJobObject(job, process))
- throw new Exception("AssignProcessToJobObject failed: " + Marshal.GetLastWin32Error());
- }
-}
-'@
-
-$candidates = @(
- "C:\Program Files\Google\Chrome\Application\chrome.exe",
- "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
- "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
-)
-$exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
-if (-not $exe) { [Console]::Error.WriteLine("no chrome or edge found"); exit 2 }
-
-$udd = Join-Path $env:TEMP "gm-e2e-$Port"
-Remove-Item -Recurse -Force $udd -ErrorAction SilentlyContinue
-
-# No --disable-gpu here: forcing software WebGL on a desktop with a real card rasterises every
-# frame on the CPU and made the host unusable. Let the machine use the card it has.
-$flags = @(
- '--headless', '--ignore-gpu-blocklist', '--renderer-process-limit=1',
- "--remote-debugging-port=$Port", "--user-data-dir=`"$udd`"", "--window-size=$Size",
- '--hide-scrollbars', '--disable-background-networking', '--disable-sync',
- '--disable-extensions', '--no-first-run', '--mute-audio', '--no-default-browser-check',
- 'about:blank'
-)
-
-$job = [GmJob]::Create()
-$proc = Start-Process -FilePath $exe -ArgumentList $flags -PassThru -NoNewWindow
-[GmJob]::Assign($job, $proc.Handle)
-# The pid file is how the caller ends this run's browser without touching the browser windows the
-# person at this desk has open.
-$pidFile = Join-Path $env:TEMP "gm-browser-$Port.pid"
-Set-Content -Path $pidFile -Value $proc.Id
-[Console]::Error.WriteLine("gm-browser: $exe pid=$($proc.Id) port=$Port job=on")
-
-# Either the browser dies on its own, or nobody is driving it any more. Both must end this script.
-# The window has to survive the gap before the first client connects, hence starting the clock now.
-$reason = "the browser exited on its own with code"
-$started = Get-Date
-$lastClient = $started
-while (-not $proc.HasExited) {
- Start-Sleep -Seconds 3
- if (((Get-Date) - $started).TotalMinutes -ge $MaxMinutes) {
- $reason = "the $MaxMinutes-minute cap"
- break
- }
- # .NET rather than Get-NetTCPConnection: that one goes through WMI, which is denied to this
- # account - its CIM session broke mid-run and took the browser down with it.
- try {
- $tcp = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpConnections()
- $clients = @($tcp | Where-Object { $_.LocalEndPoint.Port -eq $Port -and $_.State -eq 'Established' })
- } catch {
- # A probe that fails says nothing about the run; the cap above is what bounds an orphan.
- [Console]::Error.WriteLine("gm-browser: connection probe failed: $($_.Exception.Message)")
- continue
- }
- if ($clients.Count -gt 0) {
- $lastClient = Get-Date
- } elseif (((Get-Date) - $lastClient).TotalSeconds -ge $IdleSeconds) {
- $reason = "no CDP client for $IdleSeconds s"
- break
- }
-}
-
-if ($proc.HasExited) {
- [Console]::Error.WriteLine("gm-browser: $reason $($proc.ExitCode)")
-} else {
- # By pid, never by name: this host is somebody's desktop and their own Chrome must survive a test
- # run. taskkill is denied to this account, Stop-Process is not, and the job object takes the
- # renderer children when this process ends - this just makes the kill immediate.
- Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
- [Console]::Error.WriteLine("gm-browser: $reason, killed the browser")
-}
-Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 00000000..a3d7758d
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Game Maker
+
+
+
+
+
+