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 + + + + + +