The editor gets a name of its own that fits beside Brandmauer, Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the name, the URL and the stored state in the browser. Repository, CI runners, containers and the GAME_MAKER_* variables follow in stage 3. - All paths come from SITE_PATH in src/core/site.ts: the API endpoint, the data folders, the play page and the base in vite.config.ts. A later move is one line instead of fifteen. - The window title, the sign-in screen, the playground and the README say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the writing app in Ogg and Matroska follow. - The session cookie is now feuerwerk_session, so everyone signs in once more. - localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the old game-maker.* keys over on the first start, so settings, panel sizes and save games survive. - tools/ci/serve.sh serves the build under /feuerwerk/. The variable GAME_MAKER_BASE_URL keeps its name until stage 3. Outside the repository, nockal.com/feuerwerk/ is a second symlink to the same tree. nginx sends the bare /game-maker/ folder there with a 301. Everything else under /game-maker/ is still served directly, so shared play links and the API keep working. Finding: when nginx redirects a path without a trailing slash or with a query, the Brandmauer answers with its own 307 to the same URL plus a slash, placed after the query. A play link would lose its token that way, which is why only the bare folder is redirected. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests (4 new in site.test.ts). - Counter-check: when the migration misses the old prefix, 3 of the 4 new tests fail. - e2e 60 under /feuerwerk/: ALL PASSED, 20 checks. - From outside: /feuerwerk/ 200, /game-maker/ 301, an old play link 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
6.4 KiB
TypeScript
157 lines
6.4 KiB
TypeScript
/**
|
||
* 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/feuerwerk/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);
|
||
});
|
||
});
|