Share a link that plays the game without the editor (#266)

A project could only be played inside the editor, behind the login.
To show a game to someone else there was no way short of an account.
A release freezes the saved project under a random token and serves
it on a page that holds nothing but the game view.

- Menu -> Release... opens a dialog with the state of the release,
  the link with Copy link and Open, Publish or Update release, and
  Take down behind a confirmation.
- Publish saves first and refuses while anything is unsaved, like
  snapshots. Updating keeps the link. Take down kills it, and a new
  Publish hands out a new token.
- The link is play.html?game=<32 hex>. The page needs no session,
  paints Theme.colors.background, zooms with Auto and waits for
  "Click to play" so that sound and keyboard focus are allowed.
- The game always starts in the start scene. Persistent variables
  are stored per link in the browser. Plugins are not loaded, and the
  dialog warns about them and about a project without a scene.
- GET ?r=releases/{token} is the only route besides auth/* that works
  without a login. The public answer carries the token as project.id,
  so the project id stays hidden. Release files are written with 0640
  and nginx does not serve them directly (measured 403).
- Starting, the frame and Load scene moved into runtime/GameRun.ts.
  Play in the editor and the play page run through the same code.
- WorldViewport moved to src/viewport/ and no longer needs the
  EditorState. Pointer forwarding (gamePointer.ts) and setting up
  textures, fonts and sounds (projectFiles.ts) exist only once.
- Vite builds two pages. The play page loads 4 kB of its own code
  plus the shared chunk (Pixi, runtime, drawing, 653 kB) and Rapier,
  without the editor.

Found on the way, not fixed here: data/projects/<id>.json answers 200
without a login because the files are 0644.

Tests:
- ./check.sh: 194 files, 2808 tests, exit 0.
- tests/php/releases.test.php: 20 checks, also added to CI.
- New e2e module 60 with 10 checks, played in a browser without a
  session. Module 46 counts the menu entries and was updated.
- Full e2e run: 1572 checks passed.
- Counter-checks: 3 unit, 2 PHP and 3 browser mutations all fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 13:30:38 +02:00
1 parent 94c1055895
commit 4194f3e150
42 files changed
+1600 -246

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
/**
* Released games (#266): the link a project is played by, what the release dialog says about it,
* and the run a released game shares with the editor's Play – one way to start, tick, swap scenes
* and end, whichever page it is on.
*/
import { beforeAll, describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import type { SavedValues } from '../../src/core/graph';
import type { PropertyBag } from '../../src/core/model/Properties';
import { releaseToken, releaseUrl, type ReleaseSummary } from '../../src/core/store/ReleaseStore';
import { releaseState, releaseWarnings } from '../../src/editor/releases';
import { saveArea, saveKey } from '../../src/editor/saves';
import { snapshotBlocker } from '../../src/editor/snapshots';
import { GameRun, type RunServices } from '../../src/runtime/GameRun';
import { loadPhysics, type Rapier } from '../../src/runtime/physics/engine';
import { SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { Input } from '../../src/runtime/systems/Input';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
makeVariables,
place,
type SceneFixture,
} from '../helpers';
const TOKEN = '0123456789abcdef0123456789abcdef';
let physics: Rapier;
beforeAll(async () => {
physics = await loadPhysics();
});
describe('the link of a release', () => {
it('leads to the play page with the token, and the play page reads it back', () => {
const url = releaseUrl('https://nockal.com', TOKEN);
expect(url).toBe(`https://nockal.com/game-maker/play.html?game=${TOKEN}`);
expect(releaseToken(new URL(url).search)).toBe(TOKEN);
});
it('takes nothing from an address that carries no token of the right shape', () => {
expect(releaseToken('')).toBeNull();
expect(releaseToken('?game=')).toBeNull();
expect(releaseToken('?game=2072b56583af')).toBeNull();
expect(releaseToken(`?game=${TOKEN.toUpperCase()}`)).toBeNull();
expect(releaseToken(`?game=../${TOKEN}`)).toBeNull();
});
});
describe('the release dialog', () => {
const release = (revision: number): ReleaseSummary => ({
token: TOKEN,
revision,
publishedAt: '2026-09-29T10:39:31Z',
});
it('says whether the saved project has moved on since the copy was taken', () => {
expect(releaseState(null, 3)).toMatch(/^Not released/);
expect(releaseState(release(3), 3)).toMatch(
/^Released 2026-09-29 \d\d:39 · the link plays the project as saved$/,
);
expect(releaseState(release(3), 5)).toMatch(/the project has changed since$/);
});
it('warns about a project without a scene and about plugins, and about nothing else', () => {
const f = makeSpriteScene('Box');
expect(releaseWarnings(f.project)).toEqual([]);
f.project.plugins = ['inventory.js'];
expect(releaseWarnings(f.project)).toEqual([expect.stringMatching(/^Plugins are not loaded/)]);
f.project.plugins = [];
f.project.assets = f.project.assets.filter((a) => a.type !== 'scene');
expect(releaseWarnings(f.project)).toEqual([expect.stringMatching(/no scene/)]);
});
it('refuses to publish what is not saved, like a snapshot', () => {
expect(snapshotBlocker('saved', 'release')).toBeNull();
expect(snapshotBlocker('dirty', 'release')).toMatch(/not saved yet/);
expect(snapshotBlocker('error', 'release')).toMatch(/nothing current to release$/);
expect(snapshotBlocker('error')).toMatch(/nothing current to take a snapshot of$/);
});
it('keeps a released game’s saves under its link, apart from the project’s', () => {
const store = new Map<string, string>();
const memory = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
};
const saved: SavedValues = { vars: { score: 3 } };
saveArea(TOKEN, 'release', memory).write(saved);
expect([...store.keys()]).toEqual([saveKey(TOKEN, 'release')]);
expect(saveArea('project', 'play', memory).read()).toEqual({});
});
});
describe('a run of the game', () => {
function services(): RunServices {
const saved: SavedValues = {};
return {
audio: SILENT_AUDIO,
physics,
clock: { scale: 1 },
saves: { read: () => saved, write: (values) => Object.assign(saved, values) },
};
}
/** A behavior below a box that runs `type` on its first frame. */
function onStart(f: SceneFixture, type: string, params: PropertyBag = {}) {
const behavior = makeBehavior(f, 'Logic');
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, behavior.id, { x: 0, y: 0 }, box);
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const node = edit.addNode(type, params);
edit.connect(start, 'exec', node, 'exec');
}
it('reports nothing while the game just runs', () => {
const f = makeSpriteScene('Box');
place(f, f.spriteId, { x: 0, y: 0 });
const run = GameRun.start(f.project, f.sceneId, new Input(), services());
expect(run.tick(1 / 60, 1 / 60)).toBeNull();
expect(run.world.entities.map((e) => e.name)).toContain('Box');
});
it('reports a Stop game and a Restart game for the page to act on', () => {
const stopping = makeSpriteScene('Box');
onStart(stopping, 'StopGame');
expect(
GameRun.start(stopping.project, stopping.sceneId, new Input(), services()).tick(0.016, 0.016),
).toBe('stop');
const restarting = makeSpriteScene('Box');
onStart(restarting, 'RestartGame');
expect(
GameRun.start(restarting.project, restarting.sceneId, new Input(), services()).tick(0.016, 0.016),
).toBe('restart');
});
it('carries out a Load scene itself, and carries the shared variables into the new world', () => {
const f = makeSpriteScene('Box');
const second = new CreateAssetCommand('scene', 'Level 2');
f.stack.execute(second);
const vars = makeVariables(f, 'Game', ['score']);
onStart(f, 'LoadScene', { scene: second.asset.id });
const run = GameRun.start(f.project, f.sceneId, new Input(), services());
const first = run.world;
run.behaviors.liveVariables(vars.id)!.score = 7;
expect(run.tick(0.016, 0.016)).toBe('scene');
expect(run.world).not.toBe(first);
expect(run.world.sceneId).toBe(second.asset.id);
expect(run.behaviors.liveVariables(vars.id)?.score).toBe(7);
expect(run.world.entities.some((e) => e.name === 'Box')).toBe(false);
});
});