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

+82 -8
View File
@@ -9,10 +9,13 @@ import {
SetWhilePlayingCommand,
} from '../core/commands/projectCommands';
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
import { releaseUrl, type ReleaseStore } from '../core/store/ReleaseStore';
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
import { Dialog, Menu, type MenuEntry, type Widget } from '../ui';
import { dialogSlot } from './FormDialog';
import { OpenProjectDialog } from './OpenProjectDialog';
import { ReleaseDialog } from './ReleaseDialog';
import { releaseState, releaseWarnings } from './releases';
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
import { clearSaved, hasSaved } from './saves';
import { snapshotBlocker, snapshotTime } from './snapshots';
@@ -23,6 +26,7 @@ export class ProjectSession {
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
private readonly snapshotsDialog = dialogSlot<SnapshotsDialog>();
private readonly releaseDialog = dialogSlot<ReleaseDialog>();
/**
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
@@ -32,8 +36,11 @@ export class ProjectSession {
private readonly state: EditorState,
private readonly store: ProjectStore,
private readonly beforeSwitch: () => void,
/** `flush` writes what the autosave still holds, so a snapshot copies the stand on screen (#231). */
private readonly snapshots: { store: SnapshotStore; flush: () => Promise<void> },
/**
* What the server copies of the project: snapshots (#231) and the release (#266). `flush` writes
* what the autosave still holds, so a copy is of the stand on screen.
*/
private readonly copies: { snapshots: SnapshotStore; releases: ReleaseStore; flush: () => Promise<void> },
/**
* Runs on the project as it came off the server, before it is read into the model: the plugins
* this project switches on are loaded there (#119), so that what they register is in place
@@ -102,6 +109,7 @@ export class ProjectSession {
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
{ separator: true },
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
{ label: 'Release…', onSelect: () => this.openRelease() },
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
...more,
@@ -154,7 +162,7 @@ export class ProjectSession {
const projectId = this.state.project.id;
void this.run(
(async () => {
const list = await this.snapshots.store.list(projectId);
const list = await this.copies.snapshots.list(projectId);
this.snapshotsDialog.open(
new SnapshotsDialog(list, {
onCreate: (name) => void this.run(this.createSnapshot(projectId, name)),
@@ -171,14 +179,80 @@ export class ProjectSession {
return this.snapshotsDialog.current();
}
/** The link that plays only the game (#266). */
openRelease(): void {
const projectId = this.state.project.id;
void this.run(
(async () => {
const release = await this.copies.releases.of(projectId);
const link = release && releaseUrl(location.origin, release.token);
this.releaseDialog.open(
new ReleaseDialog(
release,
link,
releaseState(release, this.state.revision),
releaseWarnings(this.state.project),
{
onPublish: () => void this.run(this.publish(projectId, release !== null)),
onTakeDown: () => this.confirmTakeDown(projectId),
onCopy: (url) => void this.run(this.copyLink(url)),
onOpen: (url) => window.open(url, '_blank', 'noopener'),
},
),
);
})(),
);
}
/** The open release dialog, for the check scripts. */
releaseView(): ReleaseDialog | null {
return this.releaseDialog.current();
}
/** Publishing copies what the server has stored, so everything on screen is saved first. */
private async publish(projectId: string, again: boolean): Promise<void> {
await this.copies.flush();
const blocker = snapshotBlocker(this.state.saveStatus, 'release');
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
await this.copies.releases.publish(projectId);
this.state.setNotice(again ? 'Release updated' : 'Released – the link plays the game');
this.openRelease();
}
private confirmTakeDown(projectId: string): void {
new Dialog('Take the release down?', 'The link stops working. Publishing again makes a new one.', [
{ label: 'Cancel', onClick: () => this.openRelease() },
{
label: 'Take down',
danger: true,
onClick: () =>
void this.run(
(async () => {
await this.copies.releases.takeDown(projectId);
this.state.setNotice('Release taken down');
this.openRelease();
})(),
),
},
]).openCentered();
}
private async copyLink(url: string): Promise<void> {
await navigator.clipboard.writeText(url);
this.state.setNotice('Link copied');
}
private async createSnapshot(projectId: string, name: string): Promise<void> {
await this.snapshots.flush();
await this.copies.flush();
const blocker = snapshotBlocker(this.state.saveStatus);
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
const made = await this.snapshots.store.create(projectId, name || snapshotTime(new Date()));
const made = await this.copies.snapshots.create(projectId, name || snapshotTime(new Date()));
this.state.setNotice(`Snapshot "${made.name}" created`);
this.openSnapshots();
}
@@ -201,14 +275,14 @@ export class ProjectSession {
/** Writes the snapshot back on the server, then loads the project again: the undo history starts over. */
private async restoreSnapshot(projectId: string, snapshot: SnapshotSummary): Promise<void> {
this.beforeSwitch();
await this.snapshots.flush();
await this.copies.flush();
const blocker = snapshotBlocker(this.state.saveStatus);
if (blocker) {
this.state.setNotice(blocker, 'error');
return;
}
try {
const { before } = await this.snapshots.store.restore(projectId, snapshot.id, this.state.revision);
const { before } = await this.copies.snapshots.restore(projectId, snapshot.id, this.state.revision);
await this.open(projectId);
this.state.setNotice(`Restored "${snapshot.name}" – the previous stand is kept as "${before.name}"`);
} catch (error) {
@@ -227,7 +301,7 @@ export class ProjectSession {
onClick: () =>
void this.run(
(async () => {
await this.snapshots.store.delete(projectId, snapshot.id);
await this.copies.snapshots.delete(projectId, snapshot.id);
this.openSnapshots();
})(),
),