/** * 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(); 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); }); });