From 14972d057f877f355048fc8389a4f60fc0d0f7fb Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 29 Sep 2026 18:12:55 +0200 Subject: [PATCH] Refactoring round 8: hide data files, safe saves, Esc as Cancel (#85) Recurring cleanup round over the code since round 7 (bc688f3). The issue now also asks to fix bugs and small inconsistencies found on the way, so unlike the rounds before, this one changes behaviour in a few places. Duplicated code in api went from 23 clones with 228 lines to none (jscpd, at least 50 tokens). In src it stays at 22 clones with 271 lines, the same before and after. Bugs: - nginx served project and snapshot files as they were, so anyone who knew a project id could read it without signing in. Every file the API writes now goes through AtomicFile (temp file, mode, rename). Projects, snapshots, releases, accounts and sessions are 0640, and nginx answers 403 for them. - Any signed-in account could delete a shared image, font or sound, which projects of other accounts may wear. Deleting one is now for administrators only. The editor never deleted a file, so the client's delete is gone. - Two saves of one project could run at once. The lock file was removed after each save, so a waiting save and the next one locked different files. The lock file now stays, and deleting a project takes the lock as well. - Bytes that are no picture got a 500 instead of a 422, because the error handler also threw for warnings silenced with @. - A session file was rewritten in place. A request reading it at that moment found it empty and was signed out. It is replaced atomically. - Delete did nothing for a shape picked in the vector art list. Delete now goes through removeSelection, one case per kind of selection, and a kind without a case fails the type check. Inconsistencies: - Escape on a question does what its Cancel does. Cancel on "Delete project?" and "Restore snapshot?" leads back to the picker or the list like the other questions did, and so does deleting a project. - Editor texts use American spelling throughout, like the port names: 48 British spellings in 45 lines (colour, centre, initialised). - objectName and groupName replace eleven copies of "own name, else the asset's" and three of "Group". One copy fell back to the object id instead of "?". Duplicates, dead code, comments: - routeFiles serves images, fonts and sounds instead of three copies. HashedFileStore holds what the three stores shared: keeping a file under its hash with its name, listing and deleting. The stores keep only which bytes they take and what a record says. - AtomicFile replaces nine copies of temp file plus rename. - removeShapes is the one way the canvas, the inspector button and Delete take shapes out. - GraphInterpreter.fire was called by tests only. The 54 calls use a helper in tests/helpers.ts now. - FileStore.delete, HttpFileStore.delete and the PluginApi type were unused. A doc comment in actions.ts sat above the wrong function. Checked and left as is: - solidCells is used by tests only. It is the per-cell view before the merge into rectangles, the one place the largest-scale rule shows. - An undo checker ran all 124 commands of the unit tests and compared the project before and after undo. The only differences are empty ports, groups and notes lists where there were none, which the file format drops anyway. Docs: - README: the check sum. - Wiki, own commit: API (admin-only deletes, 422, file modes, lock file), Home (the "Not yet" list named three things that exist), Concepts, Keyboard, Vector art, Architecture and Development, and the 38 characters Gitea flagged as ambiguous on eight pages. - .gitignore: the lock files, and pasted-*.png in any folder, which keeps the screenshot archive out. Tests: - ./check.sh: 195 files, 2817 tests, exit 0 (+2 for the name helpers). - PHP: accounts 52 in the web container and 53 in the CI image with the race test (+2), snapshots 15 (+1), releases 20. - Full e2e run: ALL PASSED, 1586 checks (+6 in 12, 47, 49 and 50). - Smokes jumprun, tetris, actor: 24, 19 and 21 checks. The example project files were restored byte for byte afterwards. - The file stores of HEAD and of this commit give byte-identical output for the live folders and for store, list and delete in a throwaway folder. - Counter-checks, all fail as expected: a 500 for bad bytes fails 12, no admin check fails 49, removing the lock file again loses saves in 3 of 3 runs (151 to 170 of 321), Delete ignoring shapes fails 47, Escape that only closes fails 50, and dropping the shape case fails the type check (TS2366). Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 +- README.md | 2 +- api/index.php | 144 ++++++++---------- api/lib/AtomicFile.php | 27 ++++ api/lib/FontStore.php | 77 +--------- api/lib/HashedFileStore.php | 94 ++++++++++++ api/lib/OriginalName.php | 15 +- api/lib/PixelStore.php | 9 +- api/lib/ReleaseStore.php | 11 +- api/lib/SessionStore.php | 8 +- api/lib/SnapshotStore.php | 7 +- api/lib/SoundStore.php | 77 +--------- api/lib/Store.php | 21 +-- api/lib/TextureStore.php | 100 ++---------- api/lib/UserStore.php | 7 +- src/core/commands/animationCommands.ts | 2 +- src/core/commands/colliderCommands.ts | 4 +- src/core/commands/pixelArtCommands.ts | 4 +- src/core/commands/vectorArtCommands.ts | 2 +- src/core/graph/GraphInterpreter.ts | 22 +-- src/core/graph/nodes/action/PlaySound.ts | 2 +- src/core/graph/nodes/action/animationNodes.ts | 2 +- src/core/model/SceneObject.ts | 10 ++ src/core/model/animation.ts | 4 +- src/core/model/objectRefs.ts | 4 +- src/core/particles/nodes.ts | 6 +- src/core/shader/compile.ts | 2 +- src/core/shader/nodes.ts | 26 ++-- src/core/store/FileStore.ts | 3 +- src/core/store/HttpFileStore.ts | 4 - src/editor/ProjectSession.ts | 17 ++- src/editor/actions.ts | 56 ++++++- src/editor/animation/RowDialog.ts | 3 +- src/editor/inspector/annotationSection.ts | 4 +- src/editor/inspector/objectSection.ts | 6 +- src/editor/inspector/parentRow.ts | 4 +- src/editor/inspector/shapeSection.ts | 21 ++- src/editor/panels/GroupNameDialog.ts | 6 +- src/editor/panels/HierarchyPanel.ts | 5 +- src/editor/pixelart/PixelArtSide.ts | 4 +- src/editor/pixelart/pixelActions.ts | 12 +- src/editor/plugins/pluginApi.ts | 2 - src/editor/shader/previewPicture.ts | 3 +- src/editor/shortcuts.ts | 21 +-- src/editor/variables/VariablesEditor.ts | 3 +- src/editor/vectorart/GradientBar.ts | 4 +- src/editor/vectorart/VectorArtCanvas.ts | 12 +- src/runtime/World.ts | 4 +- src/ui/UIContext.ts | 2 +- src/ui/widgets/Dialog.ts | 12 +- src/ui/widgets/Popup.ts | 7 +- src/viewport/items.ts | 3 +- tests/core/dynamic-ports.test.ts | 6 +- tests/core/find-match.test.ts | 6 +- tests/core/find-modes.test.ts | 4 +- tests/core/folded-edge.test.ts | 4 +- tests/core/graph.test.ts | 35 ++--- tests/core/hierarchy.test.ts | 3 +- tests/core/http-store.test.ts | 7 +- tests/core/inline-defaults.test.ts | 4 +- tests/core/keep-state.test.ts | 15 +- tests/core/live-values.test.ts | 14 +- tests/core/nested-behavior.test.ts | 6 +- tests/core/nodes.test.ts | 7 +- tests/core/object-groups.test.ts | 14 ++ tests/core/port-splits.test.ts | 4 +- tests/core/property-ports.test.ts | 6 +- tests/core/shaders.test.ts | 6 +- tests/core/state-machine.test.ts | 19 +-- tests/core/variable-node.test.ts | 4 +- tests/core/variable-ports.test.ts | 6 +- tests/editor/texture-import.test.ts | 2 +- tests/helpers.ts | 17 +++ tests/php/accounts.test.php | 38 +++++ tests/php/releases.test.php | 1 + tests/php/snapshots.test.php | 2 + tools/e2e/12-textures.mjs | 11 ++ tools/e2e/47-vectorart.mjs | 22 ++- tools/e2e/49-users.mjs | 13 +- tools/e2e/50-snapshots.mjs | 53 ++++++- tools/make-user.php | 1 + 81 files changed, 663 insertions(+), 577 deletions(-) create mode 100644 api/lib/AtomicFile.php create mode 100644 api/lib/HashedFileStore.php diff --git a/.gitignore b/.gitignore index 0512a5bd..0f971aa8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /data/projects/*.json /data/projects/*.tmp +/data/projects/*.lock /data/e2e-password.txt /data/e2e-admin-password.txt /data/sessions/ @@ -19,4 +20,4 @@ /wiki/ /index.html /play.html -/pasted-*.png +pasted-*.png diff --git a/README.md b/README.md index 6e1429b3..ded69073 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1580 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1586 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/api/index.php b/api/index.php index d3ffeb94..a57830b2 100644 --- a/api/index.php +++ b/api/index.php @@ -51,15 +51,15 @@ declare(strict_types=1); * * GET ?r=textures → [{file,name,width,height,bytes,updatedAt}] * POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes} - * DELETE ?r=textures/{file} → 204 + * DELETE ?r=textures/{file} → 204 (administrators only, 403 for everybody else) * * GET ?r=fonts → [{file,name,bytes,updatedAt}] * POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes} - * DELETE ?r=fonts/{file} → 204 + * DELETE ?r=fonts/{file} → 204 (administrators only) * * GET ?r=sounds → [{file,name,bytes,updatedAt}] * POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes} - * DELETE ?r=sounds/{file} → 204 + * DELETE ?r=sounds/{file} → 204 (administrators only) * * POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130) * @@ -74,7 +74,9 @@ declare(strict_types=1); */ require __DIR__ . '/lib/AccountRules.php'; +require __DIR__ . '/lib/AtomicFile.php'; require __DIR__ . '/lib/Auth.php'; +require __DIR__ . '/lib/HashedFileStore.php'; // before the font, sound and texture stores built on it require __DIR__ . '/lib/FontStore.php'; require __DIR__ . '/lib/OriginalName.php'; require __DIR__ . '/lib/PixelStore.php'; @@ -101,6 +103,11 @@ const MAX_FONT_BYTES = 4 * 1024 * 1024; const MAX_SOUND_BYTES = 16 * 1024 * 1024; set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { + // PHP calls the handler for a call silenced with `@` as well, only with less reported: that + // warning was expected and is answered where it happens (an unreadable image is a 422). + if ((error_reporting() & $severity) === 0) { + return false; + } throw new ErrorException($message, 0, $severity, $file, $line); }); @@ -467,23 +474,38 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): Response::error('Method not allowed', 405); } -/** @param list $parts */ -function routeTextures(array $parts, string $method): never -{ - $store = new TextureStore(dirname(__DIR__) . '/data/textures'); - +/** + * Images, fonts and sounds: shared between the accounts and stored under the hash of their bytes. + * Everybody signed in uploads and lists them. Deleting one is for administrators only, because a + * project of another account may be wearing it – the editor itself never deletes a file. + * + * @param list $parts + * @param class-string $invalid what the store throws for bytes it cannot take + */ +function routeFiles( + array $parts, + string $method, + Auth $auth, + HashedFileStore $store, + int $maxBytes, + string $noun, + string $invalid, +): never { if (count($parts) === 1) { if ($method === 'GET') { Response::json($store->list()); } if ($method === 'POST') { $bytes = Response::rawBody(); - if (strlen($bytes) > MAX_TEXTURE_BYTES) { - Response::error('Image must be at most ' . (MAX_TEXTURE_BYTES / 1024 / 1024) . ' MB', 413); + if (strlen($bytes) > $maxBytes) { + Response::error("{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB', 413); } try { Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201); - } catch (InvalidImageException $e) { + } catch (RuntimeException $e) { + if (!$e instanceof $invalid) { + throw $e; + } Response::error($e->getMessage(), 422); } } @@ -491,74 +513,9 @@ function routeTextures(array $parts, string $method): never } if ($method === 'DELETE') { + $auth->requireAdmin(); if (!$store->delete($parts[1])) { - Response::error('Texture not found', 404); - } - Response::noContent(); - } - - Response::error('Method not allowed', 405); -} - -/** @param list $parts */ -function routeFonts(array $parts, string $method): never -{ - $store = new FontStore(dirname(__DIR__) . '/data/fonts'); - - if (count($parts) === 1) { - if ($method === 'GET') { - Response::json($store->list()); - } - if ($method === 'POST') { - $bytes = Response::rawBody(); - if (strlen($bytes) > MAX_FONT_BYTES) { - Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413); - } - try { - Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201); - } catch (InvalidFontException $e) { - Response::error($e->getMessage(), 422); - } - } - Response::error('Method not allowed', 405); - } - - if ($method === 'DELETE') { - if (!$store->delete($parts[1])) { - Response::error('Font not found', 404); - } - Response::noContent(); - } - - Response::error('Method not allowed', 405); -} - -/** @param list $parts */ -function routeSounds(array $parts, string $method): never -{ - $store = new SoundStore(dirname(__DIR__) . '/data/sounds'); - - if (count($parts) === 1) { - if ($method === 'GET') { - Response::json($store->list()); - } - if ($method === 'POST') { - $bytes = Response::rawBody(); - if (strlen($bytes) > MAX_SOUND_BYTES) { - Response::error('Sound must be at most ' . (MAX_SOUND_BYTES / 1024 / 1024) . ' MB', 413); - } - try { - Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201); - } catch (InvalidSoundException $e) { - Response::error($e->getMessage(), 422); - } - } - Response::error('Method not allowed', 405); - } - - if ($method === 'DELETE') { - if (!$store->delete($parts[1])) { - Response::error('Sound not found', 404); + Response::error("{$noun} not found", 404); } Response::noContent(); } @@ -642,12 +599,37 @@ try { // Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the // price of admission, so an uploaded file cannot be dropped in by a stranger. $user = $auth->require(); + $data = dirname(__DIR__) . '/data'; match ($parts[0]) { 'projects' => routeProjects($parts, $method, $user, $auth->role($user) === 'admin'), - 'textures' => routeTextures($parts, $method), - 'fonts' => routeFonts($parts, $method), - 'sounds' => routeSounds($parts, $method), + 'textures' => routeFiles( + $parts, + $method, + $auth, + new TextureStore("{$data}/textures"), + MAX_TEXTURE_BYTES, + 'Image', + InvalidImageException::class, + ), + 'fonts' => routeFiles( + $parts, + $method, + $auth, + new FontStore("{$data}/fonts"), + MAX_FONT_BYTES, + 'Font', + InvalidFontException::class, + ), + 'sounds' => routeFiles( + $parts, + $method, + $auth, + new SoundStore("{$data}/sounds"), + MAX_SOUND_BYTES, + 'Sound', + InvalidSoundException::class, + ), 'pixels' => routePixels($parts, $method), 'plugins' => routePlugins($parts, $method), default => Response::error('Not found', 404), diff --git a/api/lib/AtomicFile.php b/api/lib/AtomicFile.php new file mode 100644 index 00000000..477eff0e --- /dev/null +++ b/api/lib/AtomicFile.php @@ -0,0 +1,27 @@ + 'woff2', ]; - public function __construct(private readonly string $dir) - { - if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { - throw new RuntimeException("Cannot create font directory {$this->dir}"); - } - } - - /** A stored file name, as it appears in a project document. */ public static function isValidName(string $name): bool { return (bool) preg_match('/^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/', $name); @@ -63,64 +53,7 @@ final class FontStore throw new InvalidFontException('Font file is truncated'); } - $file = hash('sha256', $bytes) . '.' . $extension; - $path = $this->dir . '/' . $file; - if (!is_file($path)) { - $this->writeFile($path, $bytes); - } - OriginalName::write($this->dir, $file, $originalName); - - return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)]; - } - - /** - * Every stored file, newest first – what the font picker offers. Unlike an image there is - * nothing to read out of the header here: the picker shows each face by rendering with it. - * - * @return list - */ - public function list(): array - { - $items = []; - foreach (glob($this->dir . '/*.*') ?: [] as $path) { - $file = basename($path); - // Sidecars share the directory and are skipped: `{hash}.ttf.json` is not a valid name. - if (!self::isValidName($file)) { - continue; - } - $items[] = [ - 'file' => $file, - 'name' => OriginalName::read($this->dir, $file), - 'bytes' => (int) filesize($path), - 'updatedAt' => gmdate('c', (int) filemtime($path)), - ]; - } - usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); - return $items; - } - - public function delete(string $file): bool - { - if (!self::isValidName($file)) { - return false; - } - $path = $this->dir . '/' . $file; - if (!is_file($path) || !unlink($path)) { - return false; - } - OriginalName::delete($this->dir, $file); - return true; - } - - /** Temp file plus rename, so a reader never sees a half-written font (see TextureStore). */ - private function writeFile(string $path, string $bytes): void - { - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write font file'); - } - @chmod($path, 0664); + return $this->keep($bytes, $extension, $originalName); } } diff --git a/api/lib/HashedFileStore.php b/api/lib/HashedFileStore.php new file mode 100644 index 00000000..27b10c86 --- /dev/null +++ b/api/lib/HashedFileStore.php @@ -0,0 +1,94 @@ +dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { + throw new RuntimeException("Cannot create directory {$this->dir}"); + } + } + + /** A stored file name, as it appears in a project document. */ + abstract public static function isValidName(string $name): bool; + + /** + * Every stored file, newest first – what the picker of its kind offers. + * + * @return list> + */ + public function list(): array + { + $items = []; + foreach (glob($this->dir . '/*.*') ?: [] as $path) { + $file = basename($path); + // Sidecars share the directory and are skipped: `{hash}.png.json` is not a valid name. + if (!static::isValidName($file)) { + continue; + } + $items[] = [ + 'file' => $file, + 'name' => OriginalName::read($this->dir, $file), + ...$this->details($path), + 'bytes' => (int) filesize($path), + 'updatedAt' => gmdate('c', (int) filemtime($path)), + ]; + } + usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); + return $items; + } + + public function delete(string $file): bool + { + if (!static::isValidName($file)) { + return false; + } + $path = $this->dir . '/' . $file; + if (!is_file($path) || !unlink($path)) { + return false; + } + OriginalName::delete($this->dir, $file); + return true; + } + + /** + * Keeps bytes that passed the store's checks under their hash, with `$originalName` beside them + * as a caption (see OriginalName), and returns the record. Bytes stored before are not written + * again; a new name renames them. + * + * @param array $details what the record says between the name and the size + * @return array + */ + protected function keep(string $bytes, string $extension, string $originalName, array $details = []): array + { + $file = hash('sha256', $bytes) . '.' . $extension; + $path = $this->dir . '/' . $file; + if (!is_file($path)) { + AtomicFile::write($path, $bytes, AtomicFile::SERVED, 'stored file'); + } + OriginalName::write($this->dir, $file, $originalName); + + return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), ...$details, 'bytes' => strlen($bytes)]; + } + + /** + * What a record of a stored file says between its name and its size. An image reads its pixel + * size here; a font or a sound has nothing to read without decoding it. + * + * @return array + */ + protected function details(string $path): array + { + return []; + } +} diff --git a/api/lib/OriginalName.php b/api/lib/OriginalName.php index b4f52ebb..b59616aa 100644 --- a/api/lib/OriginalName.php +++ b/api/lib/OriginalName.php @@ -70,16 +70,15 @@ final class OriginalName if ($clean === '') { return; } - $path = $dir . '/' . $file . '.json'; - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; $json = json_encode(['name' => $clean], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($json === false || file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) { - if (is_file($tmp)) { - unlink($tmp); - } - return; // A nameless file is a small loss; failing the upload over it would be worse. + if ($json === false) { + return; + } + try { + AtomicFile::write($dir . '/' . $file . '.json', $json, AtomicFile::SERVED, 'name file'); + } catch (RuntimeException) { + // A nameless file is a small loss; failing the upload over it would be worse. } - chmod($path, 0664); } /** Removing a file that has no sidecar is the normal case, not a failure. */ diff --git a/api/lib/PixelStore.php b/api/lib/PixelStore.php index 6f15465e..26bd087b 100644 --- a/api/lib/PixelStore.php +++ b/api/lib/PixelStore.php @@ -54,14 +54,7 @@ final class PixelStore $file = hash('sha256', $bytes) . '.px'; $path = $this->dir . '/' . $file; if (!is_file($path)) { - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { - if (is_file($tmp)) { - unlink($tmp); - } - throw new RuntimeException('Cannot write pixel file'); - } - chmod($path, 0664); + AtomicFile::write($path, $bytes, AtomicFile::SERVED, 'pixel file'); } return ['file' => $file, 'bytes' => $length]; } diff --git a/api/lib/ReleaseStore.php b/api/lib/ReleaseStore.php index b26f305f..17824dc1 100644 --- a/api/lib/ReleaseStore.php +++ b/api/lib/ReleaseStore.php @@ -146,15 +146,6 @@ final class ReleaseStore if ($json === false) { throw new RuntimeException('Cannot encode release'); } - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json, LOCK_EX) === false) { - @unlink($tmp); - throw new RuntimeException('Cannot write release file'); - } - chmod($tmp, 0640); - if (!rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write release file'); - } + AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'release file'); } } diff --git a/api/lib/SessionStore.php b/api/lib/SessionStore.php index 811dc7cc..8ea937b1 100644 --- a/api/lib/SessionStore.php +++ b/api/lib/SessionStore.php @@ -110,9 +110,11 @@ final class SessionStore throw new RuntimeException('Invalid session token'); } $json = json_encode($data, JSON_UNESCAPED_SLASHES); - if ($json === false || file_put_contents($path, $json, LOCK_EX) === false) { - throw new RuntimeException('Cannot write session file'); + if ($json === false) { + throw new RuntimeException('Cannot encode session'); } - @chmod($path, 0640); + // Replaced rather than rewritten in place: a request reading it meanwhile would find it + // empty, and an empty session is a signed-out one. + AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'session file'); } } diff --git a/api/lib/SnapshotStore.php b/api/lib/SnapshotStore.php index 3e1906c1..79d1f980 100644 --- a/api/lib/SnapshotStore.php +++ b/api/lib/SnapshotStore.php @@ -59,12 +59,7 @@ final class SnapshotStore if ($json === false) { throw new RuntimeException('Cannot encode snapshot'); } - $path = $this->path($projectId, $id); - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write snapshot file'); - } + AtomicFile::write($this->path($projectId, $id), $json, AtomicFile::HIDDEN, 'snapshot file'); return self::summary($snapshot); } diff --git a/api/lib/SoundStore.php b/api/lib/SoundStore.php index 4c27bf38..16195470 100644 --- a/api/lib/SoundStore.php +++ b/api/lib/SoundStore.php @@ -2,15 +2,13 @@ declare(strict_types=1); /** - * Content-addressed sound store: `data/sounds/{sha256}.{ext}` – the font store's twin (see - * FontStore), for the same reasons: one file per sound however often it is uploaded, a URL that - * can be cached forever, and deleting a sound asset stays undoable because nothing here is - * removed when the last project stops referencing it. + * Content-addressed sound store: `data/sounds/{sha256}.{ext}` (see HashedFileStore). * * A sound is stored byte for byte. There is no re-encoding step as there is for images: the - * browser decodes what was picked, and nothing here could encode audio anyway. + * browser decodes what was picked, and nothing here could encode audio anyway. The picker plays a + * sound instead of showing it, and its length is not read, since only a decoder knows it. */ -final class SoundStore +final class SoundStore extends HashedFileStore { /** * Accepted formats, keyed by the bytes they start with. MP3 is the exception with no single @@ -22,14 +20,6 @@ final class SoundStore 'ID3' => 'mp3', ]; - public function __construct(private readonly string $dir) - { - if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { - throw new RuntimeException("Cannot create sound directory {$this->dir}"); - } - } - - /** A stored file name, as it appears in a project document. */ public static function isValidName(string $name): bool { return (bool) preg_match('/^[a-f0-9]{64}\.(mp3|ogg|wav|m4a)$/', $name); @@ -55,53 +45,7 @@ final class SoundStore throw new InvalidSoundException('Not a sound file – use MP3, OGG, WAV or M4A'); } - $file = hash('sha256', $bytes) . '.' . $extension; - $path = $this->dir . '/' . $file; - if (!is_file($path)) { - $this->writeFile($path, $bytes); - } - OriginalName::write($this->dir, $file, $originalName); - - return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)]; - } - - /** - * Every stored file, newest first – what the sound picker offers. A sound has nothing to show, - * so the picker plays it instead; length is not read here, since only a decoder knows it. - * - * @return list - */ - public function list(): array - { - $items = []; - foreach (glob($this->dir . '/*.*') ?: [] as $path) { - $file = basename($path); - // Sidecars share the directory and are skipped: `{hash}.mp3.json` is not a valid name. - if (!self::isValidName($file)) { - continue; - } - $items[] = [ - 'file' => $file, - 'name' => OriginalName::read($this->dir, $file), - 'bytes' => (int) filesize($path), - 'updatedAt' => gmdate('c', (int) filemtime($path)), - ]; - } - usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); - return $items; - } - - public function delete(string $file): bool - { - if (!self::isValidName($file)) { - return false; - } - $path = $this->dir . '/' . $file; - if (!is_file($path) || !unlink($path)) { - return false; - } - OriginalName::delete($this->dir, $file); - return true; + return $this->keep($bytes, $extension, $originalName); } /** The format these bytes are, `''` when they are none we take. */ @@ -138,17 +82,6 @@ final class SoundStore $version = ($second >> 3) & 0b11; return $first === 0xFF && ($second & 0xE0) === 0xE0 && $version !== 0b01 ? 'mp3' : ''; } - - /** Temp file plus rename, so a reader never sees a half-written sound (see TextureStore). */ - private function writeFile(string $path, string $bytes): void - { - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write sound file'); - } - @chmod($path, 0664); - } } /** Thrown by SoundStore::store when the uploaded bytes are not a usable sound. */ diff --git a/api/lib/Store.php b/api/lib/Store.php index ae344c4d..52a505b0 100644 --- a/api/lib/Store.php +++ b/api/lib/Store.php @@ -123,11 +123,14 @@ final class Store public function delete(string $id): bool { - if ($this->load($id) === null) { - return false; - } $path = $this->path($id); - return file_exists($path) && unlink($path); + // Under the save lock, or a save running at the same time writes the project back. + $deleted = $this->locked($path, fn(): bool => $this->load($id) !== null && unlink($path)); + if ($deleted) { + // Whoever still waits for the lock finds no project and writes nothing. + @unlink($path . '.lock'); + } + return $deleted; } /** @@ -154,6 +157,9 @@ final class Store } /** + * The lock file stays when the work is done. Removing it would let two saves in at once: one + * still waiting holds the removed file, while the next one creates a new file and locks that. + * * @template T * @param callable():T $work * @return T @@ -169,7 +175,6 @@ final class Store } finally { flock($lock, LOCK_UN); fclose($lock); - @unlink($path . '.lock'); } } @@ -221,10 +226,6 @@ final class Store if ($json === false) { throw new RuntimeException('Cannot encode project'); } - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write project file'); - } + AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'project file'); } } diff --git a/api/lib/TextureStore.php b/api/lib/TextureStore.php index 9e5e7e0b..a909a3d3 100644 --- a/api/lib/TextureStore.php +++ b/api/lib/TextureStore.php @@ -2,19 +2,13 @@ declare(strict_types=1); /** - * Content-addressed image store: `data/textures/{sha256}.{ext}`. + * Content-addressed image store: `data/textures/{sha256}.{ext}` (see HashedFileStore). * - * The name of a file is the hash of its bytes, which buys four things at once: - * uploading the same image twice costs one file, a stored file can be cached forever - * (its URL cannot ever mean different bytes), projects reference a short string instead - * of embedding megabytes of base64, and deleting a texture asset is undoable for free - * because nothing here is removed when it stops being referenced. - * - * The flip side is that files outlive the projects that used them: nothing collects the - * unreferenced ones. They stay pickable instead – `GET ?r=textures` is what the editor's - * image picker offers, so a picture one project dropped is still there for the next. + * The flip side of hashed names is that files outlive the projects that used them: nothing + * collects the unreferenced ones. They stay pickable instead – `GET ?r=textures` is what the + * editor's image picker offers, so a picture one project dropped is still there for the next. */ -final class TextureStore +final class TextureStore extends HashedFileStore { /** Accepted formats. SVG is deliberately absent: it can carry script. */ private const EXTENSIONS = [ @@ -38,14 +32,6 @@ final class TextureStore */ private const MAX_DIMENSION = 8192; - public function __construct(private readonly string $dir) - { - if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { - throw new RuntimeException("Cannot create texture directory {$this->dir}"); - } - } - - /** A stored file name, as it appears in a project document. */ public static function isValidName(string $name): bool { return (bool) preg_match('/^[a-f0-9]{64}\.(png|jpg|webp|gif)$/', $name); @@ -87,80 +73,20 @@ final class TextureStore throw new InvalidImageException('Image must be between 1 and ' . self::MAX_DIMENSION . ' pixels per side'); } - $file = hash('sha256', $bytes) . '.' . $extension; - $path = $this->dir . '/' . $file; - if (!is_file($path)) { - $this->writeFile($path, $bytes); - } - OriginalName::write($this->dir, $file, $originalName); - - return [ - 'file' => $file, - 'name' => OriginalName::read($this->dir, $file), - 'width' => $width, - 'height' => $height, - 'bytes' => strlen($bytes), - ]; + return $this->keep($bytes, $extension, $originalName, ['width' => $width, 'height' => $height]); } /** - * Every stored file, newest first – what the image picker offers. + * The pixel size, read from each header rather than left to the client: a texture asset records + * the size, so a picker that only knew the file name could not create one without decoding the + * picture first. An unreadable file reports 0 × 0 and the picker skips it. * - * The pixel size is read from each header rather than left to the client: a texture asset - * records the size, so a picker that only knew the file name could not create one without - * decoding the picture first. An unreadable file reports 0 × 0 and the picker skips it. - * - * @return list + * @return array{width:int,height:int} */ - public function list(): array + protected function details(string $path): array { - $items = []; - foreach (glob($this->dir . '/*.*') ?: [] as $path) { - $file = basename($path); - // Sidecars live in the same directory and are skipped here: `{hash}.png.json` is not - // a valid stored name. - if (!self::isValidName($file)) { - continue; - } - $size = @getimagesize($path); - $items[] = [ - 'file' => $file, - 'name' => OriginalName::read($this->dir, $file), - 'width' => (int) ($size[0] ?? 0), - 'height' => (int) ($size[1] ?? 0), - 'bytes' => (int) filesize($path), - 'updatedAt' => gmdate('c', (int) filemtime($path)), - ]; - } - usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); - return $items; - } - - public function delete(string $file): bool - { - if (!self::isValidName($file)) { - return false; - } - $path = $this->dir . '/' . $file; - if (!is_file($path) || !unlink($path)) { - return false; - } - OriginalName::delete($this->dir, $file); - return true; - } - - /** - * Writes via a temp file in the same directory, so a reader never sees a half-written - * image: the name only ever appears once the bytes behind it are complete. - */ - private function writeFile(string $path, string $bytes): void - { - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Cannot write texture file'); - } - @chmod($path, 0664); + $size = @getimagesize($path); + return ['width' => (int) ($size[0] ?? 0), 'height' => (int) ($size[1] ?? 0)]; } } diff --git a/api/lib/UserStore.php b/api/lib/UserStore.php index 21883e9b..a4759f2b 100644 --- a/api/lib/UserStore.php +++ b/api/lib/UserStore.php @@ -216,11 +216,6 @@ final class UserStore if ($json === false) { throw new RuntimeException('Cannot encode users'); } - $tmp = $this->file . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $this->file)) { - @unlink($tmp); - throw new RuntimeException('Cannot write users file'); - } - @chmod($this->file, 0640); + AtomicFile::write($this->file, $json, AtomicFile::HIDDEN, 'users file'); } } diff --git a/src/core/commands/animationCommands.ts b/src/core/commands/animationCommands.ts index 9c73cddd..67063270 100644 --- a/src/core/commands/animationCommands.ts +++ b/src/core/commands/animationCommands.ts @@ -270,7 +270,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand { else key.interpolation = this.interpolation; changed += 1; } - if (changed === 0) throw new CommandError('Only a number or colour key can ease'); + if (changed === 0) throw new CommandError('Only a number or color key can ease'); } } diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index 790d8c39..d3e9e322 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -4,7 +4,7 @@ import { isLiveInstance, type ObjectRef, resolvedHolder } from '../model/instanc import type { Asset } from '../model/Asset'; import type { PropertyBag } from '../model/Properties'; import { findAsset, type ObjectHolder, type Project } from '../model/Project'; -import { createSceneObject, type SceneObject } from '../model/SceneObject'; +import { createSceneObject, objectName, type SceneObject } from '../model/SceneObject'; import { resolveSceneObjectProps } from '../properties/resolve'; import { transformOfObject } from '../properties/transform'; import { removeById } from '../util/list'; @@ -52,7 +52,7 @@ abstract class AddPartCommand implements Command { if (requireAsset(project, this.assetId).type !== this.partType) throw new CommandError(`That asset is not a ${this.partType}`); } else { - project.assets.push(this.build(project, target, targetAsset, target.name || targetAsset.name)); + project.assets.push(this.build(project, target, targetAsset, objectName(target, targetAsset))); } holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId)); } diff --git a/src/core/commands/pixelArtCommands.ts b/src/core/commands/pixelArtCommands.ts index 244cbcb8..0ab63962 100644 --- a/src/core/commands/pixelArtCommands.ts +++ b/src/core/commands/pixelArtCommands.ts @@ -72,9 +72,9 @@ export class SetPaletteColorsCommand implements Command { execute(project: Project): void { const palette = requirePalette(project, this.paletteId); if (this.colors.length > PALETTE_MAX) - throw new CommandError(`A palette holds at most ${PALETTE_MAX} colours`); + throw new CommandError(`A palette holds at most ${PALETTE_MAX} colors`); if (!this.colors.every((c) => Number.isInteger(c) && c >= 0 && c <= 0xffffff)) - throw new CommandError('A colour is 0xRRGGBB'); + throw new CommandError('A color is 0xRRGGBB'); this.before = [...palette.colors]; palette.colors = [...this.colors]; } diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts index c50d388b..5a738c91 100644 --- a/src/core/commands/vectorArtCommands.ts +++ b/src/core/commands/vectorArtCommands.ts @@ -328,7 +328,7 @@ export class SetVectorShapeStyleCommand implements Command { throw new CommandError('An outline is wider than nothing'); for (const paint of [this.style.paint, this.style.stroke?.paint]) if (paint && paint.type !== 'texture' && paint.stops.length < MIN_STOPS) - throw new CommandError('A gradient needs two colours'); + throw new CommandError('A gradient needs two colors'); this.previous = { fill: shape.fill, opacity: shape.opacity, diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index fce99ceb..547adf84 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -105,24 +105,10 @@ export class GraphInterpreter { return this.indexOf(this.graph).ofType(type); } - fire( - type: string, - ctx: NodeContext, - outputs: Record = {}, - filter?: (node: GraphNode) => boolean, - /** Which of the event node's exec outputs the pulse leaves through – a state event names one. */ - port = 'exec', - ): number { - let fired = 0; - for (const node of this.eventNodes(type)) { - if (filter && !filter(node)) continue; - this.pulse(node, ctx, outputs, port); - fired++; - } - return fired; - } - - /** Runs the flow of one event node, whichever it is; `fire` is this for every node of a type. */ + /** + * Runs the flow of one event node, whichever it is. `port` is the exec output the pulse leaves + * through – a state event names one. + */ pulse(node: GraphNode, ctx: NodeContext, outputs: Record = {}, port = 'exec'): void { this.frame = this.emptyFrame(this.graph); this.callStack = []; diff --git a/src/core/graph/nodes/action/PlaySound.ts b/src/core/graph/nodes/action/PlaySound.ts index 07315416..7de82622 100644 --- a/src/core/graph/nodes/action/PlaySound.ts +++ b/src/core/graph/nodes/action/PlaySound.ts @@ -54,7 +54,7 @@ export const PlaySound: NodeDef = { description: 'Plays a sound asset. "volume" multiplies the volume the asset itself carries and "loop" either ' + "leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " + - 'the node only colours it. Each play is a voice of its own: firing this twice in a row is heard ' + + 'the node only colors it. Each play is a voice of its own: firing this twice in a row is heard ' + 'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' + 'unless "detached" is on.', inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort()], diff --git a/src/core/graph/nodes/action/animationNodes.ts b/src/core/graph/nodes/action/animationNodes.ts index 7813ddd6..4fefe70d 100644 --- a/src/core/graph/nodes/action/animationNodes.ts +++ b/src/core/graph/nodes/action/animationNodes.ts @@ -21,7 +21,7 @@ export const PlayAnimation: NodeDef = { description: 'Plays an animation on an object: its rows reach the object and what is placed inside it, so every ' + 'instance plays on its own. A paused one runs on from where it stands; one already running keeps its ' + - 'frame but wins again over the others on the object. Blend fades numbers and colours over that many ' + + 'frame but wins again over the others on the object. Blend fades numbers and colors over that many ' + 'seconds from what they held when it took over; everything else switches at once. Blend out is for ' + 'Once (reset): when its frames run out it fades back to where it started over that many seconds. ' + 'Restart starts one already running or paused over from its first frame – blended in from where it ' + diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts index a2486208..ffa7e2c5 100644 --- a/src/core/model/SceneObject.ts +++ b/src/core/model/SceneObject.ts @@ -42,6 +42,16 @@ export interface ObjectGroup { name: string; } +/** What an object is called: its own name, else its asset's, and `?` while the asset is gone. */ +export function objectName(object: { name: string }, asset: { name: string } | undefined): string { + return object.name || asset?.name || '?'; +} + +/** What a group is called; one that was never named is a "Group". */ +export function groupName(group: ObjectGroup): string { + return group.name || 'Group'; +} + export function createSceneObject( id: string, assetId: string, diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 52803331..f38659c1 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -16,7 +16,7 @@ import { treeOrder } from './hierarchy'; import { instanceChildId, resolveObjects } from './instance'; import { findAsset, type Project } from './Project'; import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties'; -import type { SceneObject } from './SceneObject'; +import { objectName, type SceneObject } from './SceneObject'; import { mixColor } from '../util/color'; /** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */ @@ -483,7 +483,7 @@ export function trackDef( /** How the timeline names what a row aims at: the object (or the root itself) and the property. */ export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string { const aimed = aimedAt(project, animation, track.target); - const who = !aimed ? '(missing)' : aimed.object ? aimed.object.name || aimed.asset.name : aimed.asset.name; + const who = !aimed ? '(missing)' : aimed.object ? objectName(aimed.object, aimed.asset) : aimed.asset.name; const def = aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property); return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`; diff --git a/src/core/model/objectRefs.ts b/src/core/model/objectRefs.ts index 5717b2fa..3048820f 100644 --- a/src/core/model/objectRefs.ts +++ b/src/core/model/objectRefs.ts @@ -7,7 +7,7 @@ import { findAsset, type Project } from './Project'; import { treeOrder, type ObjectTree } from './hierarchy'; import { resolveObjects } from './instance'; import { mapNested, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties'; -import type { SceneObject } from './SceneObject'; +import { objectName, type SceneObject } from './SceneObject'; import { Recorder } from '../util/recorder'; /** The id an object's place names; '' for none and for anything that is no object's place. */ @@ -59,7 +59,7 @@ export function objectChoices(project: Project, holder: ObjectTree, objectId: st const choices: ObjectChoice[] = []; for (const { object, depth } of treeOrder({ ...holder, objects })) { if (!object.id.startsWith(scope)) continue; - const label = object.name || findAsset(project, object.assetId)?.name || object.id; + const label = objectName(object, findAsset(project, object.assetId)); choices.push({ ref: object.id.slice(scope.length), label, depth }); } if (!scope) return choices; diff --git a/src/core/particles/nodes.ts b/src/core/particles/nodes.ts index b4a3752d..2f02c36a 100644 --- a/src/core/particles/nodes.ts +++ b/src/core/particles/nodes.ts @@ -229,7 +229,7 @@ export const ParticleColor: ParticleNodeDef = { type: 'ParticleColor', label: 'Color', category: 'particles', - description: 'A constant colour.', + description: 'A constant color.', inputs: [], outputs: [numberOut('color')], params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], @@ -262,7 +262,7 @@ export const ParticleOutput: ParticleNodeDef = { label: 'Output', category: 'particles', description: - 'What the particle looks like: x and y offset it from the source, the scales multiply its size, rotation turns it, colour tints it and alpha fades it.', + 'What the particle looks like: x and y offset it from the source, the scales multiply its size, rotation turns it, color tints it and alpha fades it.', inputs: [ { key: 'x', label: 'x', type: 'number', inline: false }, { key: 'y', label: 'y', type: 'number', inline: false }, @@ -423,7 +423,7 @@ export const ParticleMixColor: ParticleNodeDef = { type: 'ParticleMixColor', label: 'Mix color', category: 'particles', - description: 'Blends two colours channel by channel: t = 0 is a, t = 1 is b. A flame fading to smoke.', + description: 'Blends two colors channel by channel: t = 0 is a, t = 1 is b. A flame fading to smoke.', inputs: [colorIn('a', 0xffffff), colorIn('b', 0x000000), numberIn('t', 0.5)], outputs: [numberOut('color')], params: [], diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index cb49c13a..5b227b24 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -83,7 +83,7 @@ export function compileShader(graph: Graph): ShaderBuild { }, conform: (node, port, expr) => { if (port.type === 'number' && expr.type === 'vec3') { - walk.problem(node, `"${port.key}" needs a number, not a colour`); + walk.problem(node, `"${port.key}" needs a number, not a color`); return null; } if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' }; diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index 39aed96e..36fd0372 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -150,7 +150,7 @@ export const ShaderOutput: ShaderNodeDef = { label: 'Output', category: 'shader', description: - 'The colour and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.', + 'The color and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.', inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }], outputs: [], params: [{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS }], @@ -241,7 +241,7 @@ export const ShaderColor: ShaderNodeDef = { type: SHADER_COLOR, label: 'Color', category: 'shader', - description: 'A constant colour.', + description: 'A constant color.', inputs: [], outputs: [vec3Port('color')], params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], @@ -261,19 +261,19 @@ export const ShaderNumber: ShaderNodeDef = { emit: (_inputs, params) => ({ value: { code: floatLiteral(Number(params.value ?? 0)), type: 'float' } }), }; -export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colours.', 0); +export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colors.', 0); export const ShaderSubtract = arithmetic( 'ShaderSubtract', 'Subtract', '-', - 'a - b, per channel for colours.', + 'a - b, per channel for colors.', 0, ); export const ShaderMultiply = arithmetic( 'ShaderMultiply', 'Multiply', '*', - 'a × b, per channel for colours.', + 'a × b, per channel for colors.', 1, ); /** @@ -291,7 +291,7 @@ export const ShaderDivide: ShaderNodeDef = { type: 'ShaderDivide', label: 'Divide', category: 'shader', - description: 'a / b, per channel for colours. Dividing by 0 is 0, not a pixel the driver picks.', + description: 'a / b, per channel for colors. Dividing by 0 is 0, not a pixel the driver picks.', inputs: [anyIn('a', 1), anyIn('b', 1)], outputs: [anyOut('result')], params: [], @@ -305,7 +305,7 @@ export const ShaderClamp: ShaderNodeDef = { type: 'ShaderClamp', label: 'Clamp', category: 'shader', - description: 'Holds the value inside min..max, per channel for colours.', + description: 'Holds the value inside min..max, per channel for colors.', inputs: [anyIn('value', 0), floatIn('min', 0), floatIn('max', 1)], outputs: [anyOut('result')], params: [], @@ -332,7 +332,7 @@ export const ShaderLuminance: ShaderNodeDef = { type: 'ShaderLuminance', label: 'Luminance', category: 'shader', - description: 'How bright a colour is (Rec. 709 weights), as one number.', + description: 'How bright a color is (Rec. 709 weights), as one number.', inputs: [vec3Port('color')], outputs: [{ key: 'value', label: 'value', type: 'number' }], params: [], @@ -358,7 +358,7 @@ export const ShaderSin = unary( 'ShaderSin', 'Sin', 'sin', - 'The sine of a number in radians: one full wave every 6.283. Per channel for colours.', + 'The sine of a number in radians: one full wave every 6.283. Per channel for colors.', ); export const ShaderFloor = unary( 'ShaderFloor', @@ -376,14 +376,14 @@ export const ShaderAbs = unary( 'ShaderAbs', 'Abs', 'abs', - 'The distance from 0, sign dropped: -0.3 becomes 0.3. A value crossing 0 becomes a V. Per channel for colours.', + 'The distance from 0, sign dropped: -0.3 becomes 0.3. A value crossing 0 becomes a V. Per channel for colors.', ); export const ShaderStep: ShaderNodeDef = { type: 'ShaderStep', label: 'Step', category: 'shader', - description: '0 while value is below edge, 1 from edge on: a hard cut. Per channel for colours.', + description: '0 while value is below edge, 1 from edge on: a hard cut. Per channel for colors.', inputs: [floatIn('edge', 0.5), anyIn('value', 0)], outputs: [anyOut('result')], params: [], @@ -398,7 +398,7 @@ export const ShaderPow: ShaderNodeDef = { label: 'Pow', category: 'shader', description: - 'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0. Per channel for colours.', + 'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0. Per channel for colors.', inputs: [anyIn('value', 0), floatIn('exponent', 2)], outputs: [anyOut('result')], params: [], @@ -414,7 +414,7 @@ export const ShaderSqrt: ShaderNodeDef = { label: 'Sqrt', category: 'shader', description: - 'The square root: 0.25 becomes 0.5, 4 becomes 2. Below 0 the value counts as 0. Per channel for colours.', + 'The square root: 0.25 becomes 0.5, 4 becomes 2. Below 0 the value counts as 0. Per channel for colors.', inputs: [anyIn('value', 0)], outputs: [anyOut('result')], params: [], diff --git a/src/core/store/FileStore.ts b/src/core/store/FileStore.ts index a607a5d5..0b5894fc 100644 --- a/src/core/store/FileStore.ts +++ b/src/core/store/FileStore.ts @@ -19,10 +19,9 @@ export interface StoredFile extends UploadedFile { * so there is nothing else to send. `type` only fills the Content-Type header – the server trusts * the bytes, not the label – and uploading the same bytes twice yields the same file, the hash of * its content. `name` is the caption kept beside it; uploading the same bytes under a new name - * renames what the pickers show. + * renames what the pickers show. Nothing here deletes a file: another account's project may wear it. */ export interface FileStore { upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise; list(): Promise; - delete(file: string): Promise; } diff --git a/src/core/store/HttpFileStore.ts b/src/core/store/HttpFileStore.ts index 16f02827..465359e3 100644 --- a/src/core/store/HttpFileStore.ts +++ b/src/core/store/HttpFileStore.ts @@ -27,8 +27,4 @@ export class HttpFileStore< async list(): Promise { return apiRequest(this.endpoint, 'GET', this.route, {}); } - - async delete(file: string): Promise { - await apiRequest(this.endpoint, 'DELETE', `${this.route}/${encodeURIComponent(file)}`, {}); - } } diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 5b3bf565..96dc1909 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -150,10 +150,21 @@ export class ProjectSession { return this.pickerDialog.current(); } + /** The question stands in for the picker: Cancel goes back to it, and so does the deletion. */ private confirmDelete(id: string, name: string): void { new Dialog(`Delete project "${name}"?`, 'All assets and scenes are removed permanently.', [ - { label: 'Cancel' }, - { label: 'Delete', danger: true, onClick: () => void this.run(this.deleteProject(id)) }, + { label: 'Cancel', onClick: () => this.openProjectPicker() }, + { + label: 'Delete', + danger: true, + onClick: () => + void this.run( + (async () => { + await this.deleteProject(id); + this.openProjectPicker(); + })(), + ), + }, ]).openCentered(); } @@ -262,7 +273,7 @@ export class ProjectSession { `Restore snapshot "${snapshot.name}"?`, 'The project as it is now is kept as a snapshot of its own first, so this can be taken back.', [ - { label: 'Cancel' }, + { label: 'Cancel', onClick: () => this.openSnapshots() }, { label: 'Restore', primary: true, diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 52c77345..a1dc6cb5 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -2,6 +2,7 @@ * Editor actions that several places trigger the same way (inspector, hierarchy context * menu, keyboard): they run the command and put the selection where the user expects it. */ +import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands'; import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands'; import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands'; import { @@ -12,8 +13,10 @@ import { } from '../core/commands/colliderCommands'; import { oneStep } from '../core/commands/CompoundCommand'; import { CreateContainerCommand } from '../core/commands/containerCommands'; +import { RemoveFrameCommand } from '../core/commands/frameCommands'; import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; +import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands'; import type { AssetType } from '../core/model/AssetTypes'; import { curveUsages } from '../core/model/curve'; import { structTypeUsages } from '../core/model/structTypes'; @@ -29,7 +32,6 @@ import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../ import { Dialog } from '../ui'; import { type EditorState, objectSelection } from './state'; -/** Groups the objects into a new container asset placed in their stead; selects the container. */ /** * What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link * in the inspector): open its tab – and a scene, whose tab is the scene view, becomes the active one. @@ -39,6 +41,7 @@ export function activateAsset(state: EditorState, assetId: string): void { state.openAsset(assetId); } +/** Groups the objects into a new container asset placed in their stead; selects the container. */ export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean { const command = new CreateContainerCommand(holderId, objectIds); if (!state.execute(command)) return false; @@ -80,6 +83,18 @@ export function removeObjects(state: EditorState, holderId: string, objectIds: r return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds)); } +/** Takes shapes of a vector art out as one undo step; the drawing itself stays selected. */ +export function removeShapes(state: EditorState, assetId: string, shapeIds: readonly string[]): boolean { + if (shapeIds.length === 0) return false; + const removed = state.execute( + shapeIds.length === 1 + ? new RemoveVectorShapeCommand(assetId, shapeIds[0]!) + : new RemoveVectorShapesCommand(assetId, shapeIds), + ); + if (removed) state.select({ kind: 'asset', assetId }); + return removed; +} + /** Deletes graph nodes as one undo step, however many were selected. */ export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean { const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id)); @@ -115,6 +130,43 @@ export function removeGraphSelection( return state.execute(oneStep(label, commands)); } +/** + * What Delete does to the selection; false when nothing is selected. Every kind is a case of its + * own, so a new kind of selection cannot slip through without saying what Delete takes away. + */ +export function removeSelection(state: EditorState): boolean { + const s = state.selection; + if (!s) return false; + switch (s.kind) { + case 'asset': + confirmDeleteAsset(state, s.assetId); + return true; + case 'object': + removeObjects(state, s.holderId, s.objectIds); + return true; + case 'node': + removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds); + return true; + case 'annotation': + removeGraphSelection(state, s.assetId, [], s.annotationIds); + return true; + case 'frame': + state.execute(new RemoveFrameCommand(s.assetId, s.frameId)); + return true; + case 'shape': + removeShapes(state, s.assetId, s.shapeIds); + return true; + case 'track': + // The keys when some are picked, else the row itself – like a frame of a texture. + state.execute( + s.keys.length > 0 + ? new RemoveAnimationKeysCommand(s.assetId, s.keys) + : new RemoveAnimationTracksCommand(s.assetId, [s.trackId]), + ); + return true; + } +} + /** Asks before deleting an asset, spelling out what goes with it. */ export function confirmDeleteAsset(state: EditorState, assetId: string): void { const asset = findAsset(state.project, assetId); @@ -167,5 +219,5 @@ function wornDetail(sprites: number, animations: number, tiles: number, type: As // "a, b and c" rather than "a and b and c", which three kinds of user made reachable. const list = who.length > 2 ? `${who.slice(0, -1).join(', ')} and ${who[who.length - 1]}` : who.join(' and '); - return `${list} reference it and show the plain colour in its place.`; + return `${list} reference it and show the plain color in its place.`; } diff --git a/src/editor/animation/RowDialog.ts b/src/editor/animation/RowDialog.ts index 3b4215d2..aee0c0bc 100644 --- a/src/editor/animation/RowDialog.ts +++ b/src/editor/animation/RowDialog.ts @@ -5,6 +5,7 @@ import { import { animationTargets, targetDefs, trackDef } from '../../core/model/animation'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; import { findAsset } from '../../core/model/Project'; +import { objectName } from '../../core/model/SceneObject'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { type Button, Dropdown, Label, Menu, type MenuItem } from '../../ui'; import { dialogSlot, FormDialog } from '../FormDialog'; @@ -90,7 +91,7 @@ export class RowDialog extends FormDialog { for (const { id, object, depth } of animationTargets(this.state.project, root)) choices.push({ id, - label: `${' '.repeat(depth)}${object.name || findAsset(this.state.project, object.assetId)?.name || '?'}`, + label: `${' '.repeat(depth)}${objectName(object, findAsset(this.state.project, object.assetId))}`, }); return choices; } diff --git a/src/editor/inspector/annotationSection.ts b/src/editor/inspector/annotationSection.ts index 6013bb7e..b0302d42 100644 --- a/src/editor/inspector/annotationSection.ts +++ b/src/editor/inspector/annotationSection.ts @@ -49,7 +49,7 @@ export function buildAnnotationSection(host: InspectorHost, assetId: string, ann ); addNote( host, - 'The text is drawn black or white, whichever reads better on this colour – so any colour stays legible.', + 'The text is drawn black or white, whichever reads better on this color – so any color stays legible.', 'small', ); if (annotation.color !== undefined) @@ -103,7 +103,7 @@ function addRow( function describe(kind: AnnotationKind): string { return kind === 'group' - ? 'Nodes whose centre lies in the box move with it. Deleting the group leaves them where they are.' + ? 'Nodes whose center lies in the box move with it. Deleting the group leaves them where they are.' : 'A comment is read, not run: it frames nothing and moves alone.'; } diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 21f0d005..c62907a1 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -29,7 +29,7 @@ import { import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs'; import { type PropertyValue, textOf } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; -import type { SceneObject } from '../../core/model/SceneObject'; +import { objectName, type SceneObject } from '../../core/model/SceneObject'; import { COMMON_PROPS, isCommonProp, @@ -120,7 +120,7 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec for (const id of objectIds) { const object = findObject(view, id); const asset = object && findAsset(host.state.project, object.assetId); - if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`); + if (object && asset) addNote(host, `${objectName(object, asset)} · ${asset.type}`); } addGap(host); addNote(host, 'Ctrl-click adds or removes objects.', 'small'); @@ -136,7 +136,7 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }), ); addNote(host, 'Groups the selection into a reusable container', 'small'); - addNote(host, 'asset placed at its centre (Ctrl+G).', 'small'); + addNote(host, 'asset placed at its center (Ctrl+G).', 'small'); addGap(host); host.body.add( new Button('Remove', { diff --git a/src/editor/inspector/parentRow.ts b/src/editor/inspector/parentRow.ts index 9b5937f9..0e9c5834 100644 --- a/src/editor/inspector/parentRow.ts +++ b/src/editor/inspector/parentRow.ts @@ -1,7 +1,7 @@ import { SetParentCommand } from '../../core/commands/sceneObjectCommands'; import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; -import type { SceneObject } from '../../core/model/SceneObject'; +import { objectName, type SceneObject } from '../../core/model/SceneObject'; import { Dropdown, Label, Menu, type MenuItem } from '../../ui'; import { labelledRow } from './fieldFactory'; import type { InspectorHost } from './host'; @@ -14,7 +14,7 @@ import { viewOf } from './objectQueries'; */ export function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) { const { row } = labelledRow('Parent'); - const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?'; + const nameOf = (o: SceneObject) => objectName(o, findAsset(host.state.project, o.assetId)); const parent = findObject(holder, object.parentId); const name = parent ? nameOf(parent) : 'None'; if (fixed) { diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts index 2a737baa..e8007b35 100644 --- a/src/editor/inspector/shapeSection.ts +++ b/src/editor/inspector/shapeSection.ts @@ -14,7 +14,6 @@ import { DuplicateVectorShapeCommand, MoveVectorShapeCommand, RemoveVectorShapeCommand, - RemoveVectorShapesCommand, RenameVectorShapeCommand, SetVectorShapeStyleCommand, } from '../../core/commands/vectorArtCommands'; @@ -52,6 +51,7 @@ import { } from '../../core/model/vectorPaint'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button, HStack, Theme } from '../../ui'; +import { removeShapes } from '../actions'; import { type EditorState, shapeSelection } from '../state'; import { GradientBar } from '../vectorart/GradientBar'; import { ShapeList } from '../vectorart/ShapeList'; @@ -74,7 +74,7 @@ interface PreviewStyle { export type PaintPart = 'fill' | 'stroke'; const FILL_KIND_LABELS: Record = { - solid: 'Solid colour', + solid: 'Solid color', linear: 'Linear gradient', radial: 'Radial gradient', texture: 'Texture', @@ -112,7 +112,7 @@ const TEXTURE_DEF: PropertyDef = { function stopDefs(part: PaintPart): { color: PropertyDef; alpha: PropertyDef; offset: PropertyDef } { const prefix = part === 'fill' ? 'fillStop' : 'lineStop'; return { - color: { key: `${prefix}Color`, label: 'Stop colour', kind: 'color', default: 0xffffff }, + color: { key: `${prefix}Color`, label: 'Stop color', kind: 'color', default: 0xffffff }, alpha: { key: `${prefix}Alpha`, label: 'Stop opacity', @@ -187,7 +187,7 @@ const OPACITY_DEF: PropertyDef = { const OUTLINE_DEF: PropertyDef = { key: 'outline', label: 'Outline', kind: 'boolean', default: false }; const STROKE_COLOR_DEF: PropertyDef = { key: 'strokeColor', - label: 'Line colour', + label: 'Line color', kind: 'color', default: DEFAULT_STROKE.color, }; @@ -303,7 +303,7 @@ export function buildShapeSection( STROKE_COLOR_DEF, (s) => s.stroke?.color ?? DEFAULT_STROKE.color, (input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }), - 'Set line colour', + 'Set line color', ); else gradientRows(host, assetId, shapeId, 'stroke'); styleRow( @@ -323,10 +323,7 @@ export function buildShapeSection( host.body.add( new Button('Delete shapes', { variant: 'danger', - onClick: () => { - host.state.execute(new RemoveVectorShapesCommand(assetId, shapeIds)); - host.state.select({ kind: 'asset', assetId }); - }, + onClick: () => removeShapes(host.state, assetId, shapeIds), }), ); host.body.add(addShapeButton(host, assetId)); @@ -683,9 +680,9 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par }, label, ); - stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop colour'); + stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop color'); stopRow(defs.alpha, (stop) => stop.alpha, 'alpha', 'Set stop opacity'); - stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move colour stop'); + stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move color stop'); const remove = new Button('Remove stop', { variant: 'ghost', @@ -701,7 +698,7 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par assetId, shapeId, withGradient(shape, part, next), - 'Remove colour stop', + 'Remove color stop', ), ); }, diff --git a/src/editor/panels/GroupNameDialog.ts b/src/editor/panels/GroupNameDialog.ts index 85286219..ca35cf4e 100644 --- a/src/editor/panels/GroupNameDialog.ts +++ b/src/editor/panels/GroupNameDialog.ts @@ -1,4 +1,4 @@ -import type { ObjectGroup } from '../../core/model/SceneObject'; +import { groupName, type ObjectGroup } from '../../core/model/SceneObject'; import { type Button, Dropdown, Menu, TextField, ui } from '../../ui'; import { dialogSlot, FormDialog } from '../FormDialog'; @@ -72,8 +72,8 @@ export class GroupNameDialog extends FormDialog { return [ { label: NEW_GROUP, onSelect: pick('', NEW_GROUP) }, ...(this.join?.groups ?? []).map((g) => ({ - label: g.name || 'Group', - onSelect: pick(g.id, g.name || 'Group'), + label: groupName(g), + onSelect: pick(g.id, groupName(g)), })), ]; } diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index b1bdf072..f1bdd866 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -11,6 +11,7 @@ import { } from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; +import { groupName, objectName } from '../../core/model/SceneObject'; import { deviationMarks } from '../../core/properties/deviation'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { groupRowItems, selectGroup } from '../objectGroups'; @@ -272,7 +273,7 @@ export class HierarchyPanel extends Panel { if (row.kind === 'group') return { id: groupRowId(row.group.id), - label: row.group.name || 'Group', + label: groupName(row.group), hint: String(row.memberIds.length), icon: GROUP_ICON_SVG, iconAtEnd: true, @@ -284,7 +285,7 @@ export class HierarchyPanel extends Panel { const asset = findAsset(this.state.project, object.assetId); return { id: object.id, - label: object.name || asset?.name || '?', + label: objectName(object, asset), icon: asset && ASSET_ICON_SVG[asset.type], iconAtEnd: true, depth, diff --git a/src/editor/pixelart/PixelArtSide.ts b/src/editor/pixelart/PixelArtSide.ts index d1407d07..655271b7 100644 --- a/src/editor/pixelart/PixelArtSide.ts +++ b/src/editor/pixelart/PixelArtSide.ts @@ -120,7 +120,7 @@ export class PixelArtSide extends ScrollContainer { this.toolOptions = row(this.filledOption, this.contiguousOption, new Spacer(), this.brushButton); c.add(this.toolOptions); - c.add(heading('Colours')); + c.add(heading('Colors')); this.palette.onPick = (index) => this.canvas.setColor(index); this.palette.onReflow = () => this.relayoutContent(); c.add(this.palette); @@ -395,7 +395,7 @@ export class PixelArtSide extends ScrollContainer { this.addColorButton.setEnabled(palette !== null); this.removeColorButton.setEnabled(editable); this.paletteNote.setText( - palette ? `${palette.name} · ${palette.colors.length} colours` : 'Default colours', + palette ? `${palette.name} · ${palette.colors.length} colors` : 'Default colors', ); this.createPaletteButton.visible = asset !== null && palette === null; diff --git a/src/editor/pixelart/pixelActions.ts b/src/editor/pixelart/pixelActions.ts index 99f293d4..dd933909 100644 --- a/src/editor/pixelart/pixelActions.ts +++ b/src/editor/pixelart/pixelActions.ts @@ -167,17 +167,17 @@ export function setPaletteColor( if (index < 1 || index > palette.colors.length || palette.colors[index - 1] === color) return false; const colors = [...palette.colors]; colors[index - 1] = color; - return state.execute(new SetPaletteColorsCommand(palette.id, 'Palette colour', colors)); + return state.execute(new SetPaletteColorsCommand(palette.id, 'Palette color', colors)); } /** Appends a colour; returns its index, 0 when the palette is full. */ export function addPaletteColor(state: EditorState, palette: PaletteAsset, color: number): number { if (palette.colors.length >= PALETTE_MAX) { - state.setNotice(`A palette holds at most ${PALETTE_MAX} colours.`); + state.setNotice(`A palette holds at most ${PALETTE_MAX} colors.`); return 0; } const colors = [...palette.colors, color]; - return state.execute(new SetPaletteColorsCommand(palette.id, 'Add colour', colors)) ? colors.length : 0; + return state.execute(new SetPaletteColorsCommand(palette.id, 'Add color', colors)) ? colors.length : 0; } /** @@ -203,9 +203,9 @@ export function removePaletteColor(state: EditorState, palette: PaletteAsset, in if (next) replaced[file] = pixelCels.put(next); } if (Object.keys(replaced).length > 0) - rewrites.push(new EditPixelArtCommand(asset.id, 'Remove colour', (s) => withCelsReplaced(s, replaced))); + rewrites.push(new EditPixelArtCommand(asset.id, 'Remove color', (s) => withCelsReplaced(s, replaced))); } const colors = palette.colors.filter((_, i) => i !== index - 1); - const recolor = new SetPaletteColorsCommand(palette.id, 'Remove colour', colors); - return state.execute(oneStep('Remove colour', [recolor, ...rewrites])); + const recolor = new SetPaletteColorsCommand(palette.id, 'Remove color', colors); + return state.execute(oneStep('Remove color', [recolor, ...rewrites])); } diff --git a/src/editor/plugins/pluginApi.ts b/src/editor/plugins/pluginApi.ts index 443942c9..61f3951e 100644 --- a/src/editor/plugins/pluginApi.ts +++ b/src/editor/plugins/pluginApi.ts @@ -110,5 +110,3 @@ export function createPluginApi(pluginId: string, host: PluginHost) { ui: Object.freeze({ NodeBoard }), }); } - -export type PluginApi = ReturnType; diff --git a/src/editor/shader/previewPicture.ts b/src/editor/shader/previewPicture.ts index f5a87f9e..39a1d1db 100644 --- a/src/editor/shader/previewPicture.ts +++ b/src/editor/shader/previewPicture.ts @@ -2,6 +2,7 @@ import { findObject } from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; +import { objectName } from '../../core/model/SceneObject'; import { findFrame, resolvePicture, resolveTexture, type TextureRef } from '../../core/model/texture'; import type { FlatDef } from '../../core/properties/PropertySchema'; import { resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve'; @@ -46,7 +47,7 @@ export function previewPicture(project: Project, subject: PictureSubject): Previ const object = holder && findObject(resolvedHolder(project, holder), subject.objectId); const asset = object && findAsset(project, object.assetId); if (!object || asset?.type !== 'sprite') return null; - return spritePicture(project, object.name || asset.name, resolveSceneObjectProps(project, asset, object)); + return spritePicture(project, objectName(object, asset), resolveSceneObjectProps(project, asset, object)); } if (subject.kind === 'frame') { const found = findFrame(project, subject.frameId); diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 217682b8..9912a2c8 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -1,7 +1,5 @@ -import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands'; -import { RemoveFrameCommand } from '../core/commands/frameCommands'; import { ui } from '../ui'; -import { confirmDeleteAsset, createContainer, removeGraphSelection, removeObjects } from './actions'; +import { createContainer, removeSelection } from './actions'; import type { EditorState } from './state'; export interface ShortcutActions { @@ -45,22 +43,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds); return true; } - if (event.key === 'Delete' || event.key === 'Backspace') { - const s = state.selection; - if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds); - else if (s?.kind === 'node') removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds); - else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId)); - else if (s?.kind === 'annotation') removeGraphSelection(state, s.assetId, [], s.annotationIds); - // The keys when some are picked, else the row itself – like a frame of a texture. - else if (s?.kind === 'track') - state.execute( - s.keys.length > 0 - ? new RemoveAnimationKeysCommand(s.assetId, s.keys) - : new RemoveAnimationTracksCommand(s.assetId, [s.trackId]), - ); - else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId); - return s !== null; - } + if (event.key === 'Delete' || event.key === 'Backspace') return removeSelection(state); // Ctrl+Enter starts play and then holds/resumes it. A plain letter would be taken away from // the game, which owns the keyboard while playing. if (mod && event.key === 'Enter') { diff --git a/src/editor/variables/VariablesEditor.ts b/src/editor/variables/VariablesEditor.ts index 4e077ae2..fdc3d3e0 100644 --- a/src/editor/variables/VariablesEditor.ts +++ b/src/editor/variables/VariablesEditor.ts @@ -7,6 +7,7 @@ import type { Container } from 'pixi.js'; import { isVariableOwner } from '../../core/model/Asset'; import { findObject } from '../../core/model/hierarchy'; import { findAsset } from '../../core/model/Project'; +import { objectName } from '../../core/model/SceneObject'; import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui'; import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection'; import type { InspectorHost } from '../inspector/host'; @@ -31,7 +32,7 @@ function ownerOf(state: EditorState, target: Extract { const shapeIds = this.selectedShapeIds(); if (shapeIds.length === 0) return super.onKeyDown(event); this.endPen(); - this.state.execute( - shapeIds.length === 1 - ? new RemoveVectorShapeCommand(this.assetId, shapeIds[0]!) - : new RemoveVectorShapesCommand(this.assetId, shapeIds), - ); - this.state.select({ kind: 'asset', assetId: this.assetId }); + removeShapes(this.state, this.assetId, shapeIds); return true; } @@ -784,7 +778,7 @@ export class VectorArtCanvas extends SheetCanvas { if (this.target === 'stroke' && !shape.stroke) return 'Paint: this shape has no outline.'; return this.shownPaint() ? `Paint: drag the two handles of the ${part}.` - : `Paint: the ${part} is one colour – pick a ${this.target === 'fill' ? 'gradient or texture' : 'gradient'} in the inspector.`; + : `Paint: the ${part} is one color – pick a ${this.target === 'fill' ? 'gradient or texture' : 'gradient'} in the inspector.`; } private syncHint(asset: VectorArtAsset | null): void { diff --git a/src/runtime/World.ts b/src/runtime/World.ts index cdf9be61..82874e4a 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -11,7 +11,7 @@ import { referenceCandidates } from '../core/model/objectRefs'; import type { Cell } from '../core/model/tilemap'; import { descendantsBelow } from '../core/model/walk'; import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project'; -import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; +import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject'; import { apply, IDENTITY } from '../core/model/matrix'; import { hasTransformProps } from '../core/properties/PropertySchema'; import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform'; @@ -247,7 +247,7 @@ export class World { } entity.assetId = asset.id; entity.type = asset.type; - entity.name = object.name || asset.name; + entity.name = objectName(object, asset); entity.x = object.position.x; entity.y = object.position.y; entity.z = object.position.z; diff --git a/src/ui/UIContext.ts b/src/ui/UIContext.ts index 7b7fe32a..17bd8ad9 100644 --- a/src/ui/UIContext.ts +++ b/src/ui/UIContext.ts @@ -36,6 +36,6 @@ export function initUI(app: Application, root: Container, overlay: Container): U } export function ui(): UIContext { - if (!context) throw new Error('UI not initialised – call initUI() first'); + if (!context) throw new Error('UI not initialized – call initUI() first'); return context; } diff --git a/src/ui/widgets/Dialog.ts b/src/ui/widgets/Dialog.ts index 8baca2b5..f8ff2509 100644 --- a/src/ui/widgets/Dialog.ts +++ b/src/ui/widgets/Dialog.ts @@ -12,10 +12,16 @@ export interface DialogButton { onClick?: () => void; } -/** A modal box with title, message and a right-aligned button row. Any button closes it. */ +/** + * A modal box with title, message and a right-aligned button row. Any button closes it, and Escape + * is the plain button – the one neither primary nor danger, the Cancel – so both lead back alike. + */ export class Dialog extends Popup { + private readonly cancel: (() => void) | undefined; + constructor(title: string, message: string, buttons: DialogButton[]) { super({ modal: true, padding: Theme.padding * 2, gap: Theme.padding }); + this.cancel = buttons.find((b) => !b.primary && !b.danger)?.onClick; this.minWidth = 320; this.content.add(new Label(title, { style: 'bodyBold' })); if (message) this.content.add(new Label(message, { style: 'muted' })); @@ -30,4 +36,8 @@ export class Dialog extends Popup { } this.content.add(row); } + + protected override dismiss(): void { + this.closeThen(this.cancel)(); + } } diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts index c8248c6f..7439427f 100644 --- a/src/ui/widgets/Popup.ts +++ b/src/ui/widgets/Popup.ts @@ -151,10 +151,15 @@ export class Popup extends Widget { private readonly onEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); - this.close(); + this.dismiss(); } }; + /** What Escape does. A popup closes; a dialog makes it its Cancel. */ + protected dismiss(): void { + this.close(); + } + override measure(): Size { const c = this.content.measure(); return { w: Math.max(this.minWidth, c.w), h: Math.max(this.minHeight, c.h) }; diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 3d1b2dba..774be42b 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -22,6 +22,7 @@ import { solidRects, } from '../core/model/tilemapCollision'; import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; +import { objectName } from '../core/model/SceneObject'; import { fontFileOf, fontSmoothOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; import type { FrameOutline } from '../core/model/frameOutline'; @@ -206,7 +207,7 @@ export function collectHolderItems( items.push({ id: object.id, type: asset.type, - name: object.name || asset.name, + name: objectName(object, asset), x: m.tx, y: m.ty, z, diff --git a/tests/core/dynamic-ports.test.ts b/tests/core/dynamic-ports.test.ts index 1d01d396..2634e567 100644 --- a/tests/core/dynamic-ports.test.ts +++ b/tests/core/dynamic-ports.test.ts @@ -13,7 +13,7 @@ import { } from '../../src/core/graph'; import { graphLookup } from '../../src/core/model/graphLookup'; import type { BehaviorAsset } from '../../src/core/model/Asset'; -import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers'; function sequenceFixture() { const fixture = makeProject(); @@ -76,7 +76,7 @@ describe('a Sequence sized by what is plugged into it', () => { edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec'); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); expect(ctx.logs).toEqual(['A', 'B']); }); }); @@ -122,7 +122,7 @@ describe('a struct travelling through a nested behavior', () => { // One edge into the call, where two numbers would need two. expect(caller.graph.edges.filter((e) => e.to.node === call.id && e.to.port !== 'exec')).toHaveLength(1); const ctx = fakeContext({ variables: { result: 0 } }); - new GraphInterpreter(caller.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(caller.graph, graphLookup(fixture.project)), 'OnStart', ctx); expect(ctx.variables.result).toBe(42); }); }); diff --git a/tests/core/find-match.test.ts b/tests/core/find-match.test.ts index 2ace6b15..52f4d39f 100644 --- a/tests/core/find-match.test.ts +++ b/tests/core/find-match.test.ts @@ -14,7 +14,7 @@ import { } from '../../src/core/graph'; import type { PropertyValue } from '../../src/core/model/Properties'; import { blockFrames, BLOCK_PAD } from '../../src/editor/graph/blockFrames'; -import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers'; /** A behavior with a list variable `names` and a Find over it, the Match it brought along beside it. */ function block() { @@ -56,7 +56,7 @@ function search(names: readonly PropertyValue[], wanted: PropertyValue, by: 'ite [none, 'text', miss, 'message'], ]); const ctx = fakeContext({ variables: { names: [...names] } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } @@ -91,7 +91,7 @@ describe('Find … Match at run time', () => { [match, 'notFound', after, 'exec'], ]); const ctx = fakeContext({ variables: { names: ['a'] } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['Find: No Match tests its item']); }); }); diff --git a/tests/core/find-modes.test.ts b/tests/core/find-modes.test.ts index bc26a592..496f7dd1 100644 --- a/tests/core/find-modes.test.ts +++ b/tests/core/find-modes.test.ts @@ -13,7 +13,7 @@ import { import { graphLookup } from '../../src/core/model/graphLookup'; import type { PropertyValue } from '../../src/core/model/Properties'; import { paletteEntries, rankedEntries } from '../../src/editor/graph/paletteEntries'; -import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers'; /** A behavior with a list variable `names`, a Find over it testing `item == wanted`, and its Match. */ function block(wanted: PropertyValue = 'b', mode?: FindMode) { @@ -54,7 +54,7 @@ function answer(mode: FindMode, names: readonly PropertyValue[], ports: string[] from = { node: log, port: 'exec' }; } const ctx = fakeContext({ variables: { names: [...names] } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } diff --git a/tests/core/folded-edge.test.ts b/tests/core/folded-edge.test.ts index dc3990e7..c15771a8 100644 --- a/tests/core/folded-edge.test.ts +++ b/tests/core/folded-edge.test.ts @@ -14,7 +14,7 @@ import { foldMarkLabel, foldMarkRadius, } from '../../src/editor/graph/folding'; -import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip, fire } from '../helpers'; /** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */ function mover() { @@ -38,7 +38,7 @@ describe('folding an edge', () => { expect(behavior.graph.edges.every((e) => e.folded === true)).toBe(true); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.self.x).toBe(7); // And the edges themselves are untouched apart from the flag. expect(findAsset(project, behavior.id)).toBe(behavior); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index c4be77e1..278c8e72 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -37,6 +37,7 @@ import { makeSpriteScene, place, roundTrip, + fire, } from '../helpers'; /** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */ @@ -103,10 +104,10 @@ describe('GraphInterpreter', () => { const { behavior } = withMover(makeProject()); const interpreter = new GraphInterpreter(behavior.graph); const ctx = fakeContext({ variables: { speed: 100 } }); - const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight'); + const fired = fire(interpreter, 'OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight'); expect(fired).toBe(1); expect(ctx.owner!.x).toBe(50); - expect(interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0); + expect(fire(interpreter, 'OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0); expect(ctx.owner!.x).toBe(50); }); @@ -120,7 +121,7 @@ describe('GraphInterpreter', () => { [self, 'object', move, 'object'], ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.self.x).toBe(7); expect(ctx.self.y).toBe(-3); }); @@ -137,7 +138,7 @@ describe('GraphInterpreter', () => { [clamp, 'result', move, 'dy'], ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0 expect(ctx.self.y).toBe(1); // clamp(5, 0, 1) }); @@ -162,7 +163,7 @@ describe('GraphInterpreter', () => { const interpreter = new GraphInterpreter(behavior.graph); const run = (keys: string[]) => { const ctx = fakeContext({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) }); - interpreter.fire('OnUpdate', ctx, { dt: 0.5 }); + fire(interpreter, 'OnUpdate', ctx, { dt: 0.5 }); return { x: ctx.owner!.x, y: ctx.owner!.y }; }; expect(run([])).toEqual({ x: 0, y: 0 }); @@ -199,7 +200,7 @@ describe('GraphInterpreter', () => { previous = log; }); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']); }); @@ -226,7 +227,7 @@ describe('GraphInterpreter', () => { const interpreter = new GraphInterpreter(behavior.graph); const ctx = fakeContext({ variables: { count: 0 } }); - for (let i = 0; i < 5; i++) interpreter.fire('OnStart', ctx); + for (let i = 0; i < 5; i++) fire(interpreter, 'OnStart', ctx); expect(ctx.variables.count).toBe(3); expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']); }); @@ -237,7 +238,7 @@ describe('GraphInterpreter', () => { const edges: string[] = []; const visited: string[] = []; interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) }; - interpreter.fire('OnKeyHeld', fakeContext(), { dt: 1 }); + fire(interpreter, 'OnKeyHeld', fakeContext(), { dt: 1 }); expect(edges).toHaveLength(1); expect(visited).toContain(nodes.mul.id); expect(visited).toContain(nodes.move.id); @@ -253,7 +254,7 @@ describe('GraphInterpreter', () => { [a, 'a', b, 'exec'], [b, 'a', a, 'exec'], ]); - expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow( + expect(() => fire(new GraphInterpreter(behavior.graph), 'OnStart', fakeContext())).toThrow( /nested too deeply/, ); }); @@ -267,7 +268,7 @@ describe('GraphInterpreter', () => { [start, 'exec', repeat, 'exec'], [repeat, 'body', log, 'exec'], ]); - expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/); + expect(() => fire(new GraphInterpreter(behavior.graph), 'OnStart', fakeContext())).toThrow(/step limit/); }); }); @@ -287,7 +288,7 @@ describe('Object by name', () => { edit.wire([[join, 'text', byName, 'name']]); } const ctx = fakeContext({ findByName: (name) => (name === 'Target' ? fakeEntity('Target') : null) }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } @@ -327,7 +328,7 @@ describe('Repeat', () => { it('runs the body once per round, each round seeing its own index', () => { const { behavior } = summingLoop(5); const ctx = fakeContext({ variables: { count: 0 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); // 0+1+2+3+4. A stale data cache would add the first round's index five times (0) instead. expect(ctx.variables.count).toBe(10); }); @@ -335,7 +336,7 @@ describe('Repeat', () => { it('runs nothing at all for a count of zero', () => { const { behavior } = summingLoop(0); const ctx = fakeContext({ variables: { count: 7 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.variables.count).toBe(7); }); @@ -344,7 +345,7 @@ describe('Repeat', () => { const done = edit.addNode('Log', { message: 'done' }); edit.wire([[nodes.repeat, 'done', done, 'exec']]); const ctx = fakeContext({ variables: { count: 0 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect({ count: ctx.variables.count, logs: ctx.logs }).toEqual({ count: 3, logs: ['done'] }); }); @@ -364,7 +365,7 @@ describe('Repeat', () => { [add, 'result', set, 'count'], ]); const ctx = fakeContext({ variables: { count: 0 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.variables.count).toBe(12); }); @@ -387,7 +388,7 @@ describe('Repeat', () => { [afterAdd, 'result', after, 'count'], ]); const ctx = fakeContext({ variables: { count: 0 } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); // 3 rounds, then ×10 on the *current* count – a cache left over from the last round would give 20. expect(ctx.variables.count).toBe(30); }); @@ -429,7 +430,7 @@ describe('For each', () => { ]); } const ctx = fakeContext({ variables: { names: [...names] } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); return ctx.logs; } diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index 3697d0e0..7cbe4161 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -26,6 +26,7 @@ import { place, roundTrip, startWorld, + fire, } from '../helpers'; /** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */ @@ -264,7 +265,7 @@ describe('runtime hierarchy', () => { ], }; const ctx = fakeContext({ self: child, owner: parent, variables: {} }); - new GraphInterpreter(graph).fire('OnStart', ctx); + fire(new GraphInterpreter(graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['105', '5', '100']); }); }); diff --git a/tests/core/http-store.test.ts b/tests/core/http-store.test.ts index 3ea9f970..823e8704 100644 --- a/tests/core/http-store.test.ts +++ b/tests/core/http-store.test.ts @@ -105,17 +105,12 @@ describe('HttpFileStore', () => { ); }); - it('lists and deletes', async () => { + it('lists', async () => { stubFetch(JSON.stringify([{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }]), { status: 200, }); await expect(new HttpFileStore('/api', 'textures').list()).resolves.toEqual([ { file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }, ]); - - const mock = stubFetch(null, { status: 204 }); - await new HttpFileStore('/api', 'textures').delete(FILE); - expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`); - expect(mock.mock.calls[0]![1]?.method).toBe('DELETE'); }); }); diff --git a/tests/core/inline-defaults.test.ts b/tests/core/inline-defaults.test.ts index 8f3791e9..ee357c40 100644 --- a/tests/core/inline-defaults.test.ts +++ b/tests/core/inline-defaults.test.ts @@ -8,7 +8,7 @@ import { } from '../../src/core/graph'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import { graphLookup } from '../../src/core/model/graphLookup'; -import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers'; const untouched = { params: {} }; @@ -31,7 +31,7 @@ function logged( ...(source === text ? [] : ([[source, port, text, 'value']] as const)), ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); return ctx.logs; } diff --git a/tests/core/keep-state.test.ts b/tests/core/keep-state.test.ts index e2804b9e..ea01fdaf 100644 --- a/tests/core/keep-state.test.ts +++ b/tests/core/keep-state.test.ts @@ -32,6 +32,7 @@ import { type ProjectFixture, roundTrip, startWorld, + fire, } from '../helpers'; /** @@ -98,7 +99,7 @@ function threeCalls(fixture: ProjectFixture, callee: BehaviorAsset): unknown[][] const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); const seen: unknown[][] = []; for (let i = 0; i < 3; i++) { - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); seen.push([ctx.variables.r0, ctx.variables.r1]); } return seen; @@ -151,9 +152,9 @@ describe('Keep on a behavior variable (#229)', () => { const caller = callerOf(fixture, counter(fixture), 2); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect([ctx.variables.r0, ctx.variables.r1]).toEqual([1, 1]); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]); // The caller's own bag never learns of `count`: the kept values stay the callee's. expect('count' in ctx.variables).toBe(false); @@ -196,8 +197,8 @@ describe('Keep on a behavior variable (#229)', () => { const caller = callerOf(fixture, middle, 2); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); - interpreter.fire('OnStart', ctx); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); + fire(interpreter, 'OnStart', ctx); // One shared inner counter would have handed out 1, 2, 3, 4. expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]); }); @@ -234,8 +235,8 @@ describe('Keep on a behavior variable (#229)', () => { ); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const ctx = fakeContext(); - interpreter.fire('OnStart', ctx); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect(ctx.logs).toEqual(['first', 'first']); // A local machine of the callee is none of the caller's business either. expect(ctx.states).toEqual({}); diff --git a/tests/core/live-values.test.ts b/tests/core/live-values.test.ts index 8098313b..5cc7ce1e 100644 --- a/tests/core/live-values.test.ts +++ b/tests/core/live-values.test.ts @@ -15,7 +15,15 @@ import { graphLookup } from '../../src/core/model/graphLookup'; import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem'; import { Input } from '../../src/runtime/systems/Input'; import { World } from '../../src/runtime/World'; -import { fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place } from '../helpers'; +import { + fakeContext, + graphEditor, + makeBehavior, + makeProject, + makeSpriteScene, + place, + fire, +} from '../helpers'; function recorder() { const seen = new Map>>(); @@ -47,7 +55,7 @@ describe('live values', () => { const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); interpreter.trace = listener; const ctx = fakeContext({ variables: { count: 5, other: 9 } }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); ctx.variables.count = 99; const values = seen.get(behavior.graph)!; @@ -77,7 +85,7 @@ describe('live values', () => { const { seen, listener } = recorder(); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); interpreter.trace = listener; - interpreter.fire('OnStart', fakeContext()); + fire(interpreter, 'OnStart', fakeContext()); expect(seen.get(callee.graph)?.get(start.id)).toEqual({ in1: 21 }); expect(seen.get(callee.graph)?.get(times.id)).toEqual({ result: 42 }); diff --git a/tests/core/nested-behavior.test.ts b/tests/core/nested-behavior.test.ts index 7b266b2e..19c77241 100644 --- a/tests/core/nested-behavior.test.ts +++ b/tests/core/nested-behavior.test.ts @@ -17,7 +17,7 @@ import { import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers'; /** * A behavior that doubles what it is given: `in1` → Multiply by 2 → `out1`, reached through its @@ -57,7 +57,7 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number) function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record = {}) { const interpreter = new GraphInterpreter(asset.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); return { ctx, interpreter }; } @@ -367,7 +367,7 @@ describe('declared ports', () => { const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset; const interpreter = new GraphInterpreter(caller.graph, graphLookup(loaded)); const ctx = fakeContext({ variables: { result: 0 } }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect(ctx.variables.result).toBe(12); }); diff --git a/tests/core/nodes.test.ts b/tests/core/nodes.test.ts index ff90925b..5f2c11b6 100644 --- a/tests/core/nodes.test.ts +++ b/tests/core/nodes.test.ts @@ -8,6 +8,7 @@ import { makeSpriteScene, place, startWorld, + fire, } from '../helpers'; // Node-by-node behaviour lives in node-catalog.test.ts; what is left here needs a running @@ -73,7 +74,7 @@ describe('Spawn node', () => { return fakeEntity('Crate', position.x); }, }); - new GraphInterpreter(graph).fire('OnStart', ctx); + fire(new GraphInterpreter(graph), 'OnStart', ctx); expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]); expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log }); @@ -84,10 +85,10 @@ describe('Spawn node', () => { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)), }; - new GraphInterpreter(noAsset).fire('OnStart', ctx); + fire(new GraphInterpreter(noAsset), 'OnStart', ctx); expect(ctx.logs).toEqual(['Spawn: no asset selected', '']); const ctx2 = fakeContext({ spawn: () => null }); - new GraphInterpreter(graph).fire('OnStart', ctx2); + fire(new GraphInterpreter(graph), 'OnStart', ctx2); expect(ctx2.logs[0]).toBe('Spawn: asset crate not found'); }); }); diff --git a/tests/core/object-groups.test.ts b/tests/core/object-groups.test.ts index 63346407..ecc49937 100644 --- a/tests/core/object-groups.test.ts +++ b/tests/core/object-groups.test.ts @@ -21,6 +21,7 @@ import { } from '../../src/core/model/hierarchy'; import { instanceChildId, resolvedHolder } from '../../src/core/model/instance'; import { findAsset, findScene } from '../../src/core/model/Project'; +import { groupName, objectName } from '../../src/core/model/SceneObject'; import { serializeProject } from '../../src/core/store/serialize'; import { makeSpriteScene, place, roundTrip } from '../helpers'; @@ -211,3 +212,16 @@ describe('hierarchy groups (#241)', () => { expect(content).toEqual([x, z, y].map((id) => instanceChildId(instance, id!))); }); }); + +describe('the names the lists show', () => { + it('call an object by its own name, else by its asset, and "?" once the asset is gone', () => { + expect(objectName({ name: 'Hero' }, { name: 'Sprite' })).toBe('Hero'); + expect(objectName({ name: '' }, { name: 'Sprite' })).toBe('Sprite'); + expect(objectName({ name: '' }, undefined)).toBe('?'); + }); + + it('call a group that was never named a "Group"', () => { + expect(groupName({ id: 'g', name: '' })).toBe('Group'); + expect(groupName({ id: 'g', name: 'Crates' })).toBe('Crates'); + }); +}); diff --git a/tests/core/port-splits.test.ts b/tests/core/port-splits.test.ts index 27d8ff06..9c9cd174 100644 --- a/tests/core/port-splits.test.ts +++ b/tests/core/port-splits.test.ts @@ -26,7 +26,7 @@ import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { outputStructType, structTypeUsages } from '../../src/core/model/structTypes'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers'; /** `Loot` = { Name: "Coin", Amount: 1 } */ function lootType(f: ProjectFixture): StructAsset { @@ -126,7 +126,7 @@ describe('splitting an output', () => { { Name: 'Gem', Amount: 7 }, ]; const ctx = fakeContext({ variables: { bag } }); - new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['7']); }); diff --git a/tests/core/property-ports.test.ts b/tests/core/property-ports.test.ts index ed87b93a..312b38ca 100644 --- a/tests/core/property-ports.test.ts +++ b/tests/core/property-ports.test.ts @@ -20,7 +20,7 @@ import { import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import type { PropertyBag } from '../../src/core/model/Properties'; -import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; +import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip, fire } from '../helpers'; const SPRITE = { [OBJECT_TYPE_PARAM]: 'sprite' }; const set = (key: string): DeclaredPort => ({ key, kind: 'number', write: true }); @@ -46,7 +46,7 @@ function onSelf(ports: DeclaredPort[], params: PropertyBag = SPRITE) { function run(behavior: BehaviorAsset, fixture: ReturnType, props: PropertyBag) { const self = { ...fakeEntity('self'), props: { ...props } }; const ctx = fakeContext({ self, variables: { seen: 0 } }); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); return { props: self.props, seen: ctx.variables.seen }; } @@ -81,7 +81,7 @@ describe('a node that reads and writes properties of an object', () => { ]); const self_ = { ...fakeEntity('self'), props: { coins: 5 } }; const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); - for (let i = 0; i < 2; i++) interpreter.fire('OnStart', fakeContext({ self: self_ })); + for (let i = 0; i < 2; i++) fire(interpreter, 'OnStart', fakeContext({ self: self_ })); expect(self_.props.coins).toBe(7); }); diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 2b8bdfd4..360a4fd1 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -329,7 +329,7 @@ describe('compileShader', () => { wrong.link(doubled, 'result', out, 'alpha'); const build = compileShader(wrong.asset().graph); expect(build.compiled).toBeNull(); - expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a colour' }); + expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a color' }); }); it('names the problem on the node, and compiles nothing while there is one', () => { @@ -345,8 +345,8 @@ describe('compileShader', () => { s.link(mixed, 'result', output, 'color'); let build = compileShader(s.asset().graph); expect(build.compiled).toBeNull(); - expect(build.problems).toEqual({ [mixed]: '"t" needs a number, not a colour' }); - expect(build.reason).toBe('"t" needs a number, not a colour'); + expect(build.problems).toEqual({ [mixed]: '"t" needs a number, not a color' }); + expect(build.reason).toBe('"t" needs a number, not a color'); const dup = shaderEditor(f, 'Dup'); dup.add(SHADER_OUTPUT); diff --git a/tests/core/state-machine.test.ts b/tests/core/state-machine.test.ts index 98d028be..01bff98f 100644 --- a/tests/core/state-machine.test.ts +++ b/tests/core/state-machine.test.ts @@ -51,6 +51,7 @@ import { type ProjectFixture, roundTrip, startWorld, + fire, } from '../helpers'; /** A machine "Doors" with the states Open and Closed, added the way the tab does it. */ @@ -250,12 +251,12 @@ describe('a running state machine', () => { const behavior = build(f, machine.id, machine.ids); const ctx = fakeContext(); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project)); - interpreter.fire('OnUpdate', ctx); + fire(interpreter, 'OnUpdate', ctx); expect(ctx.logs).toEqual([]); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] }); - interpreter.fire('OnUpdate', ctx); - interpreter.fire('OnUpdate', ctx); + fire(interpreter, 'OnUpdate', ctx); + fire(interpreter, 'OnUpdate', ctx); expect(ctx.logs).toEqual(['open', 'closed']); expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] }); }); @@ -296,7 +297,7 @@ describe('a running state machine', () => { [set, 'exec', call, 'exec'], ]); const ctx = fakeContext(); - new GraphInterpreter(outer.graph, graphLookup(f.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(outer.graph, graphLookup(f.project)), 'OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] }); expect(ctx.logs).toEqual([]); }); @@ -328,12 +329,12 @@ describe('a running state machine', () => { ]); const ctx = fakeContext({ variables: { doors: machine.id } }); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project)); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); // The name moved the machine; the one nothing answers to took "not found" instead of ending. expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] }); expect(ctx.logs).toEqual(['no such state']); // The branch has no state outputs at all here, so even a state that is set leaves through else. - interpreter.fire('OnUpdate', ctx); + fire(interpreter, 'OnUpdate', ctx); expect(ctx.logs).toEqual(['no such state', 'unknown to the graph']); }); @@ -566,7 +567,7 @@ describe('reading the current state', () => { [current, 'name', log, 'message'], ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx); expect(ctx.logs).toEqual(['Open']); }); @@ -657,7 +658,7 @@ describe('transitions between states', () => { [set, 'exec', next, 'exec'], ]); const ctx = fakeContext(); - new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: closed }); }); diff --git a/tests/core/variable-node.test.ts b/tests/core/variable-node.test.ts index e547a58d..5b1fd258 100644 --- a/tests/core/variable-node.test.ts +++ b/tests/core/variable-node.test.ts @@ -5,7 +5,7 @@ import { AddVariableCommand, RenameVariableCommand } from '../../src/core/comman import { type GraphNode, GraphInterpreter, inlineValue, NO_LOOKUP, resolveDef } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; -import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers'; function setup(variables = ['count', 'copy']) { const fixture = makeProject(); @@ -19,7 +19,7 @@ function run( variables: Record = { count: 5, copy: 0 }, ) { const ctx = fakeContext({ variables: { ...variables } }); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); return ctx.variables; } diff --git a/tests/core/variable-ports.test.ts b/tests/core/variable-ports.test.ts index 4d820695..f9a6c7cc 100644 --- a/tests/core/variable-ports.test.ts +++ b/tests/core/variable-ports.test.ts @@ -21,7 +21,7 @@ import { } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; -import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; +import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip, fire } from '../helpers'; /** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */ function bothWays() { @@ -50,7 +50,7 @@ function run( ) { const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); const ctx = fakeContext({ variables }); - interpreter.fire('OnStart', ctx); + fire(interpreter, 'OnStart', ctx); return ctx.variables; } @@ -147,7 +147,7 @@ describe('a node that reads or writes several variables at once', () => { ]); const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } }); - new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx); expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 }); }); }); diff --git a/tests/editor/texture-import.test.ts b/tests/editor/texture-import.test.ts index 2c13a775..56d6fc77 100644 --- a/tests/editor/texture-import.test.ts +++ b/tests/editor/texture-import.test.ts @@ -127,7 +127,7 @@ describe('importStoredImage', () => { const FILE = `${'a'.repeat(64)}.png`; const image = { file: FILE, name: '', width: 64, height: 48, bytes: 900 }; /** The picker takes the image from the list; nothing here ever uploads. */ - const textures = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as TextureStore; + const textures = { upload: vi.fn(), list: vi.fn() } as unknown as TextureStore; function setup(): EditorState { const state = new EditorState(); diff --git a/tests/helpers.ts b/tests/helpers.ts index f3e147d3..3efd806e 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -22,6 +22,7 @@ import { VARIABLES, VARIABLES_PARAM, } from '../src/core/graph'; +import type { GraphInterpreter } from '../src/core/graph/GraphInterpreter'; import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset'; import { findObject } from '../src/core/model/hierarchy'; import { worldPosition } from '../src/core/properties/transform'; @@ -289,6 +290,22 @@ export function runDef( return def.run!(ctx, inputs, params, def, entry, lookup); } +/** + * Runs every event node of `type` in the graph, in the order the graph holds them, and answers how + * many ran. A game orders its events across all behaviors instead (#264); a test of one graph does not. + */ +export function fire( + interpreter: GraphInterpreter, + type: string, + ctx: NodeContext, + outputs: Record = {}, + filter?: (node: GraphNode) => boolean, +): number { + const nodes = interpreter.eventNodes(type).filter((node) => !filter || filter(node)); + for (const node of nodes) interpreter.pulse(node, ctx, outputs); + return nodes.length; +} + /** Runs one node definition in isolation and returns its data outputs. */ export function runNode( type: string, diff --git a/tests/php/accounts.test.php b/tests/php/accounts.test.php index 73baf951..683f301e 100644 --- a/tests/php/accounts.test.php +++ b/tests/php/accounts.test.php @@ -8,6 +8,7 @@ declare(strict_types=1); */ require __DIR__ . '/../../api/lib/AccountRules.php'; +require __DIR__ . '/../../api/lib/AtomicFile.php'; require __DIR__ . '/../../api/lib/Store.php'; require __DIR__ . '/../../api/lib/UserStore.php'; @@ -112,6 +113,43 @@ check('transfer keeps the revision', $moved['revision'] === 2, $moved['revision' $other = (new Store($projects, 'carl'))->create('Carl\'s game'); check('an administrator deletes somebody else\'s project', $admin->delete($other['id']) && $admin->listOf('carl') === []); +// The web server serves `data/` as static files, so a readable project would skip the sign-in. +check('a project file is not readable for the web server', (fileperms("{$projects}/{$id}.json") & 0o007) === 0); + +// --- Store: saves at the same time ------------------------------------------------------------- + +// Every worker saves with the revision it just read and tries again when it was beaten to it, so +// no save may be lost: each one that succeeded has to show in the revision. +if (!function_exists('pcntl_fork')) { + echo "SKIP saves at the same time (this PHP has no pcntl)\n"; +} else { + $raceDir = "{$dir}/race"; + $raceId = (new Store($raceDir, 'anna'))->create('Race')['id']; + $workers = 8; + $rounds = 40; + $children = []; + for ($w = 0; $w < $workers; $w++) { + $pid = pcntl_fork(); + if ($pid === 0) { + $store = new Store($raceDir, 'anna'); + for ($won = 0; $won < $rounds;) { + try { + $store->save($raceId, $store->load($raceId)['revision'], ['name' => 'Race', 'by' => $w]); + $won++; + } catch (StaleRevisionException) { + } + } + exit(0); + } + $children[] = $pid; + } + foreach ($children as $pid) { + pcntl_waitpid($pid, $status); + } + $final = (new Store($raceDir, 'anna'))->load($raceId)['revision']; + check("{$workers} tabs saving at once lose none of the saves", $final === 1 + $workers * $rounds, $final); +} + // --- AccountRules ------------------------------------------------------------------------------ check('renaming yourself is refused', AccountRules::renameRefusal('boss', 'BOSS') !== null); diff --git a/tests/php/releases.test.php b/tests/php/releases.test.php index d91be487..ebb41db9 100644 --- a/tests/php/releases.test.php +++ b/tests/php/releases.test.php @@ -6,6 +6,7 @@ declare(strict_types=1); * with `php tests/php/releases.test.php` and reads the last line. */ +require __DIR__ . '/../../api/lib/AtomicFile.php'; require __DIR__ . '/../../api/lib/Store.php'; require __DIR__ . '/../../api/lib/ReleaseStore.php'; diff --git a/tests/php/snapshots.test.php b/tests/php/snapshots.test.php index 72d0e627..63704886 100644 --- a/tests/php/snapshots.test.php +++ b/tests/php/snapshots.test.php @@ -6,6 +6,7 @@ declare(strict_types=1); * with `php tests/php/snapshots.test.php` and reads the last line. */ +require __DIR__ . '/../../api/lib/AtomicFile.php'; require __DIR__ . '/../../api/lib/Store.php'; require __DIR__ . '/../../api/lib/SnapshotStore.php'; @@ -36,6 +37,7 @@ $second = $snapshots->create('p1', ['revision' => 5] + $doc, 'second'); check('a snapshot keeps its name and the revision it copied', $first['name'] === 'first' && $first['revision'] === 4, $first); check('two snapshots get two ids', $first['id'] !== $second['id']); check('the summary carries no project', !isset($first['project'])); +check('the snapshot file is not readable for the web server', (fileperms("{$dir}/p1/{$first['id']}.json") & 0o007) === 0); $listed = $snapshots->list('p1'); check( diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index cb06c67a..a8b60f60 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -416,6 +416,15 @@ export async function textures(t) { (await json('__gm.state.notice')) === 'Added the image to the project.', JSON.stringify(await json('__gm.state.notice')), ); + // Bytes that are no picture are the caller's mistake: a 422 that says so, not a server error. + const refusedUpload = await evaluate(`fetch('/game-maker/api/index.php?r=textures&name=x.png', + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: 'hello' }) + .then(async (r) => ({ status: r.status, error: (await r.json()).error }))`); + check( + 'bytes that are no picture are turned away with 422, not a server error', + refusedUpload.status === 422 && refusedUpload.error === 'Not a readable image', + JSON.stringify(refusedUpload), + ); // Reusing a file already in the project must not make a second asset on it – two assets on one // picture would disagree about its filter. @@ -486,4 +495,6 @@ export async function textures(t) { (await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length")) === spritesBefore, ); } + // Saved before the next step: one that navigates would hang at the unsaved-changes prompt. + await t.saved(); } diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index b7900df2..6a1aa025 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -398,6 +398,24 @@ export async function vectorArt(t) { JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }), ); + // Delete takes a shape away however it was picked. A row of the list leaves nothing focused, so the + // key reaches the editor's shortcuts rather than the canvas. + await clickText('Add shape'); + await settle(); + const spare = (await shapeIds()).at(-1); + const spareRow = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(spare)})`); + await click(spareRow.x + spareRow.w / 2, spareRow.y + spareRow.h / 2); + await settle(); + const pickedSpare = await json('__gm.vectorArt.selected()'); + await press('Delete'); + await settle(); + const afterDelete = await shapeIds(); + check( + 'Delete removes a shape picked in the list', + pickedSpare === spare && afterDelete.length === before47e && !afterDelete.includes(spare), + JSON.stringify({ spare, pickedSpare, afterDelete }), + ); + // 47e-ii. The duplicate mark puts a copy right above its shape and selects it; a double-click on // a name edits it in the row. Both are undone again, the blocks after count on the shapes. const fieldName = (await shapeOf('va-field')).name; @@ -883,7 +901,7 @@ export async function vectorArt(t) { JSON.stringify(addedPaint?.stops), ); - const stopField = await json("__gm.inspectorField('Stop colour', true)"); + const stopField = await json("__gm.inspectorField('Stop color', true)"); await click(stopField.x, stopField.y); await press('a', { ctrl: true }); await typeText('ff0000'); @@ -892,7 +910,7 @@ export async function vectorArt(t) { const reddened = (await sky())?.paint; const redMiddle = await tabPixel(128, 128); check( - 'the Stop colour row recolours the picked stop, and the middle of the shape turns red', + 'the Stop color row recolors the picked stop, and the middle of the shape turns red', reddened?.stops[2]?.color === 0xff0000 && colourDistance(redMiddle, '#ff0000') < 24, JSON.stringify({ stops: reddened?.stops, redMiddle }), ); diff --git a/tools/e2e/49-users.mjs b/tools/e2e/49-users.mjs index 5b1880ac..c67fbcd5 100644 --- a/tools/e2e/49-users.mjs +++ b/tools/e2e/49-users.mjs @@ -99,12 +99,23 @@ export async function users(t) { JSON.stringify(userMenu), ); // A session of its own: step 45 changes the password of `e2e`, which ends the tools' cached one. - const refused = await call(await logIn(baseUrl, E2E_USER, await e2ePassword()), 'GET', 'users'); + const userCookie = await logIn(baseUrl, E2E_USER, await e2ePassword()); + const refused = await call(userCookie, 'GET', 'users'); check( `the API answers ${E2E_USER} 403 on the accounts`, refused.status === 403 && (await refused.json()).error === 'Administrators only', String(refused.status), ); + // An image, font or sound is shared, and a project of another account may wear it. The name is + // valid but nobody's, so nothing is deleted either way. + const nobodys = `textures/${'0'.repeat(64)}.png`; + const byUser = (await call(userCookie, 'DELETE', nobodys)).status; + const byAdmin = (await asAdmin('DELETE', nobodys)).status; + check( + 'only an administrator may delete a shared file', + byUser === 403 && byAdmin === 404, + JSON.stringify({ byUser, byAdmin }), + ); // 49b. Signed in as the administrator, the fixture – which belongs to `e2e` – opens all the // same, and the menu has the user management before the plugins. diff --git a/tools/e2e/50-snapshots.mjs b/tools/e2e/50-snapshots.mjs index c2a379e4..51fa8857 100644 --- a/tools/e2e/50-snapshots.mjs +++ b/tools/e2e/50-snapshots.mjs @@ -97,7 +97,27 @@ export async function snapshots(t) { revision: await json('__gm.state.revision'), }; - // 50c. Restore puts the snapshot back, keeps the renamed stand as "Before restoring …", reloads + // 50c. Escape on the question is its Cancel: the list comes back and nothing is restored. + const cameBack = (expression, label) => + waitUntil(expression, { label }).then( + () => true, + () => false, + ); + await openSnapshots(); + await clickLast('Clean start'); + await clickLast('Restore'); + await press('Escape'); + const listBack = await cameBack('__gm.snapshots() !== null', 'the list came back'); + check( + 'Escape on "Restore snapshot?" leads back to the list and restores nothing', + listBack && + (await json('__gm.state.project.name')) === RENAMED && + (await json('__gm.state.revision')) === later.revision, + JSON.stringify({ listBack, name: await json('__gm.state.project.name') }), + ); + if (listBack) await clickLast('Cancel'); + + // Restore puts the snapshot back, keeps the renamed stand as "Before restoring …", reloads // the project with an empty undo history, and tells so. await openSnapshots(); await clickLast('Clean start'); @@ -159,8 +179,35 @@ export async function snapshots(t) { ); await clickLast('Cancel'); - // 50f. Deleting the project deletes its snapshots: the route answers 404 afterwards. - await api('DELETE', `projects/${made.id}`); + // 50f. The project goes by the picker. Its question stands in for the picker: Cancel leads back to + // it, and so does the deletion, with the project gone from the list. + await clickToolbar('menu'); + await clickText('Open project…'); + await waitUntil('__gm.projectPicker() !== null', { label: 'the project picker opened' }); + await clickLast('Delete'); + await clickLast('Cancel'); + const pickerBack = await cameBack('__gm.projectPicker() !== null', 'the picker came back'); + check( + 'Cancel on "Delete project?" leads back to the picker and keeps the project', + pickerBack && (await stored()).name === RENAMED, + JSON.stringify({ pickerBack, picker: await json('__gm.projectPicker()') }), + ); + if (pickerBack) { + await clickLast('Delete'); + await clickLast('Delete'); + } else await api('DELETE', `projects/${made.id}`); + const pickerAgain = await cameBack( + `__gm.projectPicker() !== null && !__gm.projectPicker().names.includes(${JSON.stringify(RENAMED)})`, + 'the picker came back without the project', + ); + check( + 'deleting from the picker leads back to it, without the project', + pickerAgain, + JSON.stringify(await json('__gm.projectPicker()')), + ); + if (pickerAgain) await clickLast('Cancel'); + + // Deleting the project deletes its snapshots: the route answers 404 afterwards. const gone = await api('GET', `projects/${made.id}/snapshots`); check('deleting the project takes its snapshots along', gone.status === 404, String(gone.status)); } finally { diff --git a/tools/make-user.php b/tools/make-user.php index 2f7de385..795c269e 100644 --- a/tools/make-user.php +++ b/tools/make-user.php @@ -11,6 +11,7 @@ declare(strict_types=1); * php tools/make-user.php --list */ +require __DIR__ . '/../api/lib/AtomicFile.php'; require __DIR__ . '/../api/lib/UserStore.php'; $file = dirname(__DIR__) . '/data/users.json';