Plugins belong to the project: Plugins describes the load order and why it has to come before deserializing, the switch as an undo step on the project, the project switch and the open delivery of every plugin file, API drops PUT ?r=plugins/{file} and active and names the project field, Architecture lists projectPlugins, prepare and syncTo and the Plugins tab's own icon, Concepts names the puzzle piece, Development carries the check sum 1129

admin committed 2026-09-20 14:24:32 +02:00
1 parent 33101390e1
commit b6614c51e4
5 files changed
+62 -38

No files matched your search

+5 -3
@@ -20,9 +20,8 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| GET | `sounds` | `[{file,name,bytes,updatedAt}]` — what the sound picker offers |
| POST | `sounds&name=jump.wav` | **raw sound bytes** → `{file,name,bytes}` (201) |
| DELETE | `sounds/{file}` | 204 |
| GET | `plugins` | `[{file,bytes,updatedAt,active}]` — every `.js` in `data/plugins/`, sorted by file name, switched on or not |
| GET | `plugins/{file}` | the module source as `text/javascript` (`nosniff`, `no-cache`); 403 while the plugin is switched off, 404 for anything but a bare file name there — see [Plugins](Plugins) |
| PUT | `plugins/{file}` | `{active}` → the entry as above. Switches a plugin on or off, server-wide, stored in `data/plugins/plugins.json`; 422 when `active` is not a boolean |
| GET | `plugins` | `[{file,bytes,updatedAt}]` — every `.js` in `data/plugins/`, sorted by file name |
| GET | `plugins/{file}` | the module source as `text/javascript` (`nosniff`, `no-cache`); 404 for anything but a bare file name there — see [Plugins](Plugins) |
**Everything but `auth/*` needs a session** and answers **401** `{"error":"Sign in first"}` without
one. Projects belong to an account: `projects` lists only the caller's own, and somebody else's
@@ -30,6 +29,9 @@ project answers **404**, not 403 — that an id exists is already more than a st
Images, fonts, sounds and plugins are shared between accounts; being signed in is still the price
of admission. See [Accounts](Accounts).
Which editor plugins a project switches on is the project's own field `plugins` (#119); the server
has no switch of its own and hands out every plugin file it has.
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.
+13 -8
@@ -68,7 +68,7 @@
```
web/index.html Vite source page (dev) index.html built page (generated, served)
src/core/model/ Project (object holders), Asset + AssetTypes, SceneObject, Properties (value types), hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), usages (where an asset is used), container (the cycle guard), placement (what may be dropped where), texture (frames, the slice grid, a frame's collision outlines), polygon (an outline checked, cut into convex pieces and clipped to a rectangle), tile (the three kinds and their block shapes, reading a tile's pictures, the animation step), autotile (which quarter tile a neighbourhood asks for, the 47/16 tiles a block produces, 1 for a plain tile), tilemap (the cells of a layer, which neighbours a cell is joined to, the cells a stroke or a rectangle covers), tilemapCollision (the solid cells of a map, drawn in or out by their tile's collision scale and merged into rectangles, the outlined cells as polygons – an autotile's outlines cut into its quarters and pieced together per cell like its picture –, and where one sits in the world), animation (which frame at time t, a row's value there, what a row may aim at, the players on an object; KeyRef and its lookups) + animationPreview (the root at a frame, written into copies), graphLookup (what a graph sees of the project: behaviors to run, state machines to read), Scope (where a state machine's current state lives: per instance or one for the run — variables are shared by living on a Variables asset instead)
src/core/model/ Project (object holders), Asset + AssetTypes, SceneObject, Properties (value types), hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), usages (where an asset is used), container (the cycle guard), placement (what may be dropped where), texture (frames, the slice grid, a frame's collision outlines), polygon (an outline checked, cut into convex pieces and clipped to a rectangle), tile (the three kinds and their block shapes, reading a tile's pictures, the animation step), autotile (which quarter tile a neighbourhood asks for, the 47/16 tiles a block produces, 1 for a plain tile), tilemap (the cells of a layer, which neighbours a cell is joined to, the cells a stroke or a rectangle covers), tilemapCollision (the solid cells of a map, drawn in or out by their tile's collision scale and merged into rectangles, the outlined cells as polygons — an autotile's outlines cut into its quarters and pieced together per cell like its picture —, and where one sits in the world), animation (which frame at time t, a row's value there, what a row may aim at, the players on an object; KeyRef and its lookups) + animationPreview (the root at a frame, written into copies), graphLookup (what a graph sees of the project: behaviors to run, state machines to read), Scope (where a state machine's current state lives: per instance or one for the run — variables are shared by living on a Variables asset instead)
src/core/properties/ PropertySchema, kinds (one row per value kind), listItems (an item's kind, read off its value), resolve (the only override merge), deviation (what is gold), collider/camera/bounds geometry (with the side a one-way collider blocks from), convex (capsules and polygons: rounded convex pieces against each other), ray (where a ray meets a shape, in double precision), rigidbody (what the engine reads off a rigidbody, a collider's material and the scene), aspect, enabled, editorRun, assetRef/textureRef (how a reference reads), declaredRows (the row a Property node declares in a shader or particle graph, and the params that node has in both)
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, RenameCommand base (undone by renaming back: variable, struct field, port), one import command shape for texture/font/sound, require.ts (validated lookups, and what an instance may not change), naming.ts
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter, dropTarget, nested (behaviors as nodes),
@@ -78,7 +78,9 @@ src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter
lookup (what a node may look up: behaviors, state machines), keyRepeat; nodes/{events,flow,data,action,variable}/<Name>.ts
src/core/particles/ nodes (the catalogue), compile (a graph → one function per output),
emission (which particles are alive at a second — a pure rule, no state)
src/core/store/ serialize (untrusted JSON → Project), tilemapCells (the compact form of a layer's cells in the file), http (one API call), ProjectStore/HttpProjectStore,
src/core/store/ serialize (untrusted JSON → Project), projectPlugins (which plugins a project switches on, read off the raw
file before it is deserialized, #119), tilemapCells (the compact form of a layer's cells in the file),
http (one API call), ProjectStore/HttpProjectStore (load takes a `prepare` that sees the raw JSON first),
AuthStore/HttpAuthStore (signing in and out, #177; changing the password, #182),
FileStore (what the server holds of one kind) + HttpFileStore (one generic client over the three
file collections) — TextureStore, FontStore and SoundStore are its type parameters, nothing else
@@ -106,10 +108,10 @@ src/editor/ App → Editor (layout + ticker), PlaySession (the run),
EditorRun (run in editor), state, actions, shortcuts, debugHook,
textProbe (finding widgets by their text — the ground floor of the test hook, shared with the sign-in screen),
auth/LoginScreen + auth/PasswordDialog + auth/loginRules (the sign-in screen and the change-password dialog in the canvas, and what either may complain about, #177/#182), autosave,
polygonEdit (corners of an outline: what is under the pointer, whether a point is inside, put / move / take away – scene view and Texture tab),
polygonEdit (corners of an outline: what is under the pointer, whether a point is inside, put / move / take away — scene view and Texture tab),
fileImport (drop/paste) → assetImport (one upload pipeline, an ImportKind per sort) ← textureImport / fontImport / soundImport,
FilePicker (the shared "+ → Texture / Font / Sound" dialog; ImagePicker/FontPicker/SoundPicker only describe their row),
framing (fit/marquee/snap maths), FormDialog (the small modal forms: label column, Cancel + confirm, one open at a time), AssetTabEditor (the shared shell of the asset tabs), TabHeader (the bar across the top of a tab – asset tabs, Plugins tab, NodeBoard),
framing (fit/marquee/snap maths), FormDialog (the small modal forms: label column, Cancel + confirm, one open at a time), AssetTabEditor (the shared shell of the asset tabs), TabHeader (the bar across the top of a tab — asset tabs, Plugins tab, NodeBoard),
TabStrip + tabRow (the tab row and what it does when the tabs outgrow it; plugin tabs are inserted at run time),
assetTabs (which asset kind opens into which tab, and so which kind has an editor at all),
TabButton (how a tab is drawn: the card of the one showing, plain weight, no pressed fill, #176),
@@ -117,7 +119,9 @@ src/editor/ App → Editor (layout + ticker), PlaySession (the run),
after a close, a tab going on to another document — pure, so checkable without a browser).
One editor per kind serves every open document of that kind: `Editor` points it at the one on
screen (`AssetTabEditor.showAsset`), and it keeps each document's view through `saveView`/`restoreView`
src/editor/plugins/ startPlugins (list, import, host) → loader (PluginManager: order, checks, switching on and off, what became of each file), pluginApi (API v1, the per-plugin scope), PluginsPane (the Plugins tab), NodeBoard (api.ui)
src/editor/plugins/ startPlugins (list, import, host; runs before the first project) → loader (PluginManager: syncTo makes the
running plugins match what the project asks for, order, checks, what became of each file), pluginApi
(API v1, the per-plugin scope), PluginsPane (the Plugins tab), NodeBoard (api.ui)
src/editor/viewport/ WorldViewport base → FreeViewport (a camera of its own: fit, pan, wheel zoom) → SceneViewport, PreviewCanvas;
GameViewport follows the game's camera instead — editing tools, hence not in src/viewport/
src/editor/inspector/ Inspector panel, host (what a section builds into), field factory, previewHandlers (scrub → preview → command), valueSpecs
@@ -126,11 +130,11 @@ src/editor/inspector/ Inspector panel, host (what a section builds into), field
every variable), portSection, aspectAction, objectQueries/vectorRows/parentRow
src/editor/panels/ AssetPanel, assetGroups (groups by type and their order, #176), assetIcons (one SVG per
asset type in icons/, shared by the panel, the + menu, the tabs and Scene objects,
plus the List and Game tabs' own two, which stand for no asset),
plus the List, Game and Plugins tabs' own three, which stand for no asset),
assetMenu (the two groups of the + menu), HierarchyPanel, Toolbar,
TilemapPanel (tools, tiles and layers of the one selected tilemap, beside the scene view, #142;
painting itself is SceneViewport)
src/editor/graph/ GraphEditor (header, breadcrumb), GraphCanvas, graphView (zoom limits, grid, panAndZoom – shared with NodeBoard and the Tile tab's TileCanvas), EdgeLayer (curves and fold marks), graphMenus,
src/editor/graph/ GraphEditor (header, breadcrumb), GraphCanvas, graphView (zoom limits, grid, panAndZoom — shared with NodeBoard and the Tile tab's TileCanvas), EdgeLayer (curves and fold marks), graphMenus,
NodeView, NodePalette + paletteEntries (which also ranks a search), folding (hidden links, collapsed nodes), Breadcrumb + trail (the path into nested behaviors), beads (the run's pulses),
AnnotationView + annotationDrag (groups and comments: what a drag carries, each thing moved once)
src/editor/container/ ContainerEditor (header), ContainerCanvas (a SceneViewport on a template)
@@ -151,7 +155,8 @@ api/ PHP backend: index.php (routing and the sign-in gate),
lib/{Store,TextureStore,FontStore,SoundStore,PluginStore,OriginalName,Response}.php,
lib/{Auth,UserStore,SessionStore}.php (#177: accounts in data/users.json, sessions in data/sessions/)
data/projects/ persisted projects, data/textures/ + data/fonts/ + data/sounds/ content-addressed uploads,
data/plugins/ editor plugins and plugins.json (which are switched on); filled from plugins/ by the build and the seeder
data/plugins/ editor plugins, filled from plugins/ by the build and the seeder; which of them run is written
in each project (#119)
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, assets/ (the walk demo's sheet)
```
+3 -2
@@ -77,8 +77,9 @@ one kind can be open at once — two textures, three behaviors — each with its
- The tab shows the **icon** of its kind, then the name, so a behavior and a texture both called
`Player` stay apart; the `»` menu lists hidden documents the same way. **Scene** and **Game**
carry an icon too (#176) — the scene one from the asset panel, a play sign for the Game tab.
Only **Plugins** and a plugin's own tab go without one. A long name is cut with an
carry an icon too (#176) — the scene one from the asset panel, a play sign for the Game tab,
a puzzle piece for **Plugins** (#119). Only a plugin's own tab goes without one, as it brings
no picture of its own. A long name is cut with an
ellipsis. Renaming an asset renames its tab.
- A document tab always shows its own asset. Selecting something elsewhere never changes it; there
is no picker in the tab's header any more. Selecting what lives only inside a document — a node,
+1 -1
@@ -13,7 +13,7 @@ npm run check # typecheck + lint + format:check + tests, the gate CI runs
npm run build # writes dist/ and regenerates index.html
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
php tools/make-user.php <name> <password> # an account; every tool signs in (see Accounts)
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1126 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 1129 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
./e2e.sh status # live progress, or the last run's result
+40 -24
@@ -1,14 +1,15 @@
A **plugin** extends the editor. It is one JavaScript file – an ES module – in `data/plugins/` on the
server, and the editor loads every file there when it starts. v1 is deliberately small: it offers what
A **plugin** extends the editor. It is one JavaScript file — an ES module — in `data/plugins/` on the
server, and the editor loads the ones the open project asks for. v1 is deliberately small: it offers what
the demo plugin needs (read the assets, add a tab, show nodes on a board) and nothing it does not.
**Where plugins come from.** A file is put into `data/plugins/` by hand; there is no upload yet
(#116). A file put there is **off** until someone switches it on in the **Plugins** tab (#114). The plugins that ship with the repository
(#116). A file put there is **off** in every project until someone switches it on in the **Plugins**
tab (#114, #119). The plugins that ship with the repository
live in `plugins/` and are copied to `data/plugins/` by `npm run build` and by `tools/seed-e2e.mjs`;
a file of the same name is replaced, anything else in the folder is left alone, and a shipped
plugin is switched on – the build only when the state does not name it yet, the seeder always.
`data/plugins/` is not in the repository. The setting is **server-wide**: every project on the server sees the same
plugins. Plugins run **in the editor only**, never in a running game or an export.
a file of the same name is replaced, anything else in the folder is left alone.
`data/plugins/` is not in the repository. Which plugins run is decided **by the project** (#119):
the folder says what is available, the open project says what is switched on. Plugins run **in the
editor only**, never in a running game or an export.
## The format
@@ -34,9 +35,19 @@ the same reason `api.tabs.add` accepts only widgets from `api.ui`.
## Loading
The editor asks `GET ?r=plugins` for the list, then imports each **switched-on** file through
`GET ?r=plugins/{file}` (see [API](API)) – **in file-name order**, one after the other, after the
project has loaded. A plugin that is off is never fetched, so none of its code runs. Nothing a plugin does wrong stops the editor or the other plugins. Each of these
The editor asks `GET ?r=plugins` for the list, fetches the project file, and imports each file the
**project** names through `GET ?r=plugins/{file}` (see [API](API)) — **in file-name order**, one
after the other. Only then is the project read into the model.
That order is the point of #119: what a plugin registers has to exist before the project is
deserialized, because anything the editor does not know at that moment is dropped as unknown. So a
plugin can bring node types and asset kinds along later on, and a project using them still opens.
Opening another project switches over: the plugins it does not name are deactivated first, then its
own are activated. A plugin the project does not name is never fetched, so none of its code runs.
A name that is not a file on the server is passed over in silence.
Nothing a plugin does wrong stops the editor or the other plugins. Each of these
leaves the plugin inactive, with a line in the console and a notice in the toolbar:
- the file does not load (a syntax error, a failed request),
@@ -51,32 +62,37 @@ no tab behind.
## Switching a plugin on and off
The **Plugins** tab lists every file in `data/plugins/` with its name, version, file and state, each
with a switch. The state is stored on the server in `data/plugins/plugins.json`
(`{"demo-plugin.js": {"active": true}}`), so it holds for everyone who opens this editor, for every
project.
with a switch. The switch belongs to the **open project**: it is kept in the project file as
`"plugins": ["demo-plugin.js"]`, so it travels with the project and says nothing about any other
one. A new project switches on nothing.
A switch takes effect at once, without reloading the editor:
- **On** stores it, then loads and activates the plugin – after every plugin that is running
already, whatever the file names say.
- **Off** stores it, calls the plugin's `deactivate()`, and then takes away what it registered
through the API: its tabs, their content and its `change` listeners. Whoever was looking at one
of its tabs lands on Scene.
- **On** writes it into the project, then loads and activates the plugin — after every plugin that
is running already, whatever the file names say.
- **Off** writes it into the project, calls the plugin's `deactivate()`, and then takes away what it
registered through the API: its tabs, their content and its `change` listeners. Whoever was
looking at one of its tabs lands on Scene.
- It is an **undo step** like any other change to the project, and undo switches the plugin back.
- A plugin that is off has no name on its row until it has run once in this session: reading a name
out of it would mean running it.
- What the plugin did outside the API – a timer it started, something it wrote to the server – the
- What the plugin did outside the API — a timer it started, something it wrote to the server — the
editor cannot take back. That is the same right-to-everything as in **Limits** below.
- When the server does not store the switch, nothing changes and the toolbar says why.
- When the project does not take the switch, nothing changes and the toolbar says why.
Since the switch is the project's, the server can no longer know whether a file is in use: it hands
out **every** plugin it has, and only the editor decides what runs. That was decided in #117 and is
of no consequence while nothing secret lives in a plugin; #115 and #116 have to take it into account.
## API v1
| Member | What it does |
|---|---|
| `api.version` | `1` |
| `api.project.assets()` | The assets of the open project in list order, as `{ id, name, type }` – copies, so writing to them changes nothing. |
| `api.project.assets()` | The assets of the open project in list order, as `{ id, name, type }` — copies, so writing to them changes nothing. |
| `api.project.on('change', fn)` | Calls `fn` whenever the asset list changes: an asset created, renamed or deleted, by hand, by undo or by loading another project. A change that leaves ids, names and types alone (moving an object, editing a property) does not call it. Returns a function that unsubscribes. A listener that throws is reported and stays subscribed. Any event but `'change'` throws. |
| `api.tabs.add({ id, label, create })` | Adds a tab after the fixed ones (Scene, Game, Plugins), ahead of the open documents; it stays for as long as the plugin is on. `create()` is called once and must return a widget from `api.ui`. Returns `false`, with a console warning, when the id is taken – by a built-in tab (`scene`, `graph`, …) or a plugin that came earlier; `create` is not called then. A missing `id` or `label`, or a `create` that is not a function, throws. |
| `new api.ui.NodeBoard()` | A read-only node graph: `setNodes([{ id, title, x, y }])` replaces what it shows, `fit()` frames it. It has a **Fit** button and a node count above the canvas; a drag with any button pans, the wheel zooms about the pointer. The nodes are drawn by the graph editor's own node view, empty but for their title – no ports, no editing, no selection. The first `setNodes` with anything in it frames the nodes once the tab has a size; after that the view stays where the user put it. A title that is not text is empty, a position that is not a number is 0, and something that is not a list throws. |
| `api.tabs.add({ id, label, create })` | Adds a tab after the fixed ones (Scene, Game, Plugins), ahead of the open documents; it stays for as long as the plugin is on. `create()` is called once and must return a widget from `api.ui`. Returns `false`, with a console warning, when the id is taken — by a built-in tab (`scene`, `graph`, …) or a plugin that came earlier; `create` is not called then. A missing `id` or `label`, or a `create` that is not a function, throws. |
| `new api.ui.NodeBoard()` | A read-only node graph: `setNodes([{ id, title, x, y }])` replaces what it shows, `fit()` frames it. It has a **Fit** button and a node count above the canvas; a drag with any button pans, the wheel zooms about the pointer. The nodes are drawn by the graph editor's own node view, empty but for their title — no ports, no editing, no selection. The first `setNodes` with anything in it frames the nodes once the tab has a size; after that the view stays where the user put it. A title that is not text is empty, a position that is not a number is 0, and something that is not a list throws. |
## The demo plugin
@@ -86,7 +102,7 @@ creating, renaming or deleting an asset shows at once. e2e step 38 checks exactl
## Limits
- **A plugin runs with every right the editor has** – it is code on the page, and the `api` object
- **A plugin runs with every right the editor has** — it is code on the page, and the `api` object
is a convenience, not a boundary: nothing stops a plugin from calling the PHP API itself. Accepted
for v1, open in #115.
- No writing to the project, no node types of its own, no asset types, no inspector rows, no hooks