diff --git a/README.md b/README.md index 1b49effb..90204337 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,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, 1272 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1278 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/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 4faf00e1..692e5926 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -1,13 +1,20 @@ import { createEmptyGraph } from '../graph/Graph'; import { newId } from '../ids'; -import type { Asset, FontAsset, SoundAsset, StateMachineAsset, TextureAsset } from '../model/Asset'; +import type { + Asset, + FontAsset, + SoundAsset, + StateMachineAsset, + TextureAsset, + VariablesAsset, +} from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; import { isSoundFile } from '../model/sound'; import { removeSubtrees } from '../model/hierarchy'; import type { PropertyBag, PropertyValue } from '../model/Properties'; -import { objectHolders, type Project } from '../model/Project'; +import { findAssetOf, objectHolders, type Project } from '../model/Project'; import { type Scope, scopeOf, setScope } from '../model/Scope'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile } from '../model/texture'; @@ -270,6 +277,40 @@ export class SetMachineScopeCommand implements Command { } } +/** Whether a Variables asset keeps its values past the end of the run (#166). */ +export class SetVariablesPersistentCommand implements Command { + readonly label: string; + private previous = false; + + constructor( + private readonly assetId: string, + private readonly persistent: boolean, + ) { + this.label = persistent ? 'Make variables persistent' : 'Make variables per run'; + } + + execute(project: Project): void { + const asset = this.target(project); + this.previous = asset.persistent === true; + setPersistent(asset, this.persistent); + } + + undo(project: Project): void { + setPersistent(this.target(project), this.previous); + } + + private target(project: Project): VariablesAsset { + const asset = findAssetOf(project, this.assetId, 'variables'); + if (!asset) throw new CommandError(`Variables ${this.assetId} not found`); + return asset; + } +} + +function setPersistent(asset: VariablesAsset, on: boolean): void { + if (on) asset.persistent = true; + else delete asset.persistent; +} + /** * Which state a machine starts in, as a state node's id – `''` takes the mark off again, and the * machine is back to starting in no state. Only one state can be the start because there is only diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index a55f5c9b..7b6fdd3e 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -8,6 +8,7 @@ export * from './nested'; export * from './NodeContext'; export * from './NodeRegistry'; export * from './propertyPorts'; +export * from './savedValues'; export * from './sharedState'; export * from './stateMachine'; export * from './values'; diff --git a/src/core/graph/savedValues.ts b/src/core/graph/savedValues.ts new file mode 100644 index 00000000..290b5b4d --- /dev/null +++ b/src/core/graph/savedValues.ts @@ -0,0 +1,55 @@ +import type { VariablesAsset } from '../model/Asset'; +import type { PropertyBag, PropertyValue } from '../model/Properties'; +import type { Project } from '../model/Project'; +import { coerceValue } from '../properties/PropertySchema'; + +/** What a save area holds: the values of each persistent Variables asset, keyed by asset id. */ +export type SavedValues = Record; + +/** + * Where the values of persistent Variables assets outlive the run (#166). Played and editor runs + * are handed different areas, so trying a behavior in the editor leaves the player's save alone. + */ +export interface SaveArea { + read(): SavedValues; + write(saved: SavedValues): void; +} + +export function isPersistent(asset: VariablesAsset): boolean { + return asset.persistent === true; +} + +function persistentAssets(project: Project): VariablesAsset[] { + return project.assets.filter((a): a is VariablesAsset => a.type === 'variables' && isPersistent(a)); +} + +/** + * Puts what was saved over the declared start values. The declaration is the truth: a key it no + * longer has is ignored, and a value is coerced to the kind it declares now. + */ +export function restoreSaved( + project: Project, + variables: Record, + saved: SavedValues, +): void { + for (const asset of persistentAssets(project)) { + const stored = saved[asset.id]; + const bag = variables[asset.id]; + if (!stored || !bag) continue; + for (const def of asset.variables) + if (def.key in stored) bag[def.key] = coerceValue(def, stored[def.key] as PropertyValue); + } +} + +/** The declared values of every persistent asset as they stand in the run – what a save writes. */ +export function savedSnapshot(project: Project, variables: Record): SavedValues { + const saved: SavedValues = {}; + for (const asset of persistentAssets(project)) { + const bag = variables[asset.id]; + if (!bag) continue; + const values: PropertyBag = {}; + for (const def of asset.variables) if (def.key in bag) values[def.key] = bag[def.key]!; + saved[asset.id] = values; + } + return saved; +} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index ab2eab1e..2b12070a 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -40,10 +40,14 @@ export interface BehaviorAsset extends AssetBase { * Declarations shared by the whole run: a `Variables` node naming this asset reads and writes one * value per key, however many graphs point at it. Nothing is placed, so nothing overrides it – * `props` holds the starting values, as a behavior's does for its own variables. + * + * A `persistent` asset keeps its values past the end of the run (#166): the next run starts from + * what the last one left, not from `props`. Absent is not persistent. */ export interface VariablesAsset extends AssetBase { type: 'variables'; variables: PropertyDef[]; + persistent?: boolean; } /** Placed as a child of the object it belongs to; its position is the offset from that body. */ diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 47552bfd..59ab795b 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -29,6 +29,7 @@ import type { TextureFrame, GradientStop, TilemapLayer, + VariablesAsset, VectorGradient, VectorPaint, VectorPoint, @@ -169,7 +170,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { graph: normalizeGraph(a.graph), }); } else if (type === 'variables') { - assets.push({ id, type, name, props, variables: variables.get(id) ?? [] }); + const shared: VariablesAsset = { id, type, name, props, variables: variables.get(id) ?? [] }; + if (a.persistent === true) shared.persistent = true; + assets.push(shared); } else if (type === 'statemachine') { const machine: StateMachineAsset = { id, diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index 86f06449..33142374 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -7,6 +7,7 @@ import { Input } from '../runtime/systems/Input'; import { type ModelWrite, World } from '../runtime/World'; import { soundLibrary } from '../viewport/SoundLibrary'; import type { LiveMachineState } from './graph/GraphCanvas'; +import { saveArea } from './saves'; import type { EditorState } from './state'; export interface EditorRunHooks { @@ -123,6 +124,7 @@ export class EditorRun { this.behaviors = addGameSystems(fresh, project, this.input, { shouldRun: (e) => runsInEditor(e.props), audio: soundLibrary, + saves: saveArea(project.id, 'editor'), }); this.world = fresh; this.hadFocus = this.hooks.focused(); diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index f1e1f112..b2cae1c5 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -18,6 +18,7 @@ import { Input } from '../runtime/systems/Input'; import { World } from '../runtime/World'; import { ui } from '../ui'; import { soundLibrary } from '../viewport/SoundLibrary'; +import { saveArea } from './saves'; import type { GameViewport } from './viewport/GameViewport'; import type { GraphEditor } from './graph/GraphEditor'; import type { LiveMachineState } from './graph/GraphCanvas'; @@ -137,6 +138,7 @@ export class PlaySession { audio: soundLibrary, physics, clock: gameClock, + saves: saveArea(runtime.id, 'play'), }); this.host.gameView.setWorld(world); this.host.showGame(); @@ -164,6 +166,7 @@ export class PlaySession { carried, physics, clock: gameClock, + saves: saveArea(runtime.id, 'play'), }); this.host.gameView.setWorld(world); this.state.setNotice(this.playNotice(), 'quiet'); diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index b97676b1..9c55d4ec 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -7,6 +7,7 @@ import { Dialog, Menu, type MenuEntry, type Widget } from '../ui'; import { dialogSlot } from './FormDialog'; import { OpenProjectDialog } from './OpenProjectDialog'; import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog'; +import { clearSaved, hasSaved } from './saves'; import type { EditorState } from './state'; export class ProjectSession { @@ -81,10 +82,19 @@ export class ProjectSession { { label: 'Open project…', onSelect: () => this.openProjectPicker() }, { separator: true }, { label: 'Project settings…', onSelect: () => this.openProjectSettings() }, + { label: 'Clear saved data', onSelect: () => this.clearSavedData() }, ]; new Menu(items).openBelow(anchor); } + /** Forgets what persistent variables saved in this browser (#166), so the next run is a first start. */ + private clearSavedData(): void { + const id = this.state.project.id; + const had = hasSaved(id); + clearSaved(id); + this.state.setNotice(had ? 'Saved data cleared' : 'Nothing saved yet'); + } + private async createNext(): Promise { const list = await this.store.list(); await this.create(`Project ${list.length + 1}`); diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 43b6e486..6da24daf 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -3,7 +3,9 @@ import { ResetAssetPropertyCommand, SetAssetPropertyCommand, SetMachineScopeCommand, + SetVariablesPersistentCommand, } from '../../core/commands/assetCommands'; +import { isPersistent } from '../../core/graph/savedValues'; import { nextFieldName, nextVariableName } from '../../core/commands/naming'; import { AddStructFieldCommand, @@ -27,8 +29,9 @@ import { } from '../../core/properties/PropertySchema'; import { markedAssetSchema } from '../../core/properties/deviation'; import { isModified, resolveAssetProps } from '../../core/properties/resolve'; -import { Dropdown, HStack, Label, Menu, Theme } from '../../ui'; +import { Button, Dropdown, HStack, Label, Menu, Theme } from '../../ui'; import { FontPreview } from '../font/FontPreview'; +import { clearSaved, hasSaved } from '../saves'; import { SoundPreview } from '../sound/SoundPreview'; import { aspectActions, sizeCommand } from './aspectAction'; import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory'; @@ -49,11 +52,13 @@ export function assetSignature(project: Project, assetId: string): string { if (!asset) return 'missing'; // A machine's own rows are none, so its scope has to be named here or switching it rebuilds nothing. const machine = asset.type === 'statemachine' ? `|${scopeOf(asset)}` : ''; + // The same for the switch that keeps shared values between runs: its notes and button depend on it. + const kept = asset.type === 'variables' ? `|${isPersistent(asset)}` : ''; // Vector art shows its shapes under its rows (#129), so a shape added or renamed is a new structure. const shapes = asset.type === 'vectorart' ? `|${shapeListSignature(asset)}` : ''; const values = resolveAssetProps(project, asset); const rows = schemaForAsset(project, asset).filter((def) => isRowShown(def, values)); - return rows.map(defSignature).join(',') + machine + shapes; + return rows.map(defSignature).join(',') + machine + kept + shapes; } export function defSignature(def: PropertyDef): string { @@ -76,11 +81,7 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void { buildProps(host, asset, markedAssetSchema(host.state.project, asset)); if (asset.type === 'behavior' || asset.type === 'variables') buildVariables(host, asset); - // Two lines rather than one: the panel is narrow enough to cut a longer note off mid-sentence. - if (asset.type === 'variables') { - addNote(host, 'One value each for the whole run.', 'small'); - addNote(host, 'Every graph pointing here shares them.', 'small'); - } + if (asset.type === 'variables') buildPersistence(host, asset); if (asset.type === 'statemachine') buildMachineScope(host, asset); // The drawing itself: its shapes are picked here as a texture's frames are picked in its tab (#129). if (asset.type === 'vectorart') buildShapeListSection(host, asset); @@ -206,6 +207,37 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAss } } +/** Not in the schema table either: a row there would share the bag with the declared values. */ +const PERSISTENT: PropertyDef = { key: 'persistent', label: 'Persistent', kind: 'boolean', default: false }; + +/** Whether the values outlive the run (#166), what that means, and the way back to a first start. */ +function buildPersistence(host: InspectorHost, asset: VariablesAsset): void { + const set = (value: PropertyValue): void => + void host.state.execute(new SetVariablesPersistentCommand(asset.id, value === true)); + const row = createFieldRow(host, PERSISTENT, isPersistent(asset), { onInput: set, onChange: set }); + host.body.add(row.row); + // Two lines rather than one: the panel is narrow enough to cut a longer note off mid-sentence. + if (!isPersistent(asset)) { + addNote(host, 'One value each for the whole run.', 'small'); + addNote(host, 'Every graph pointing here shares them.', 'small'); + return; + } + addNote(host, 'Kept between runs, in this browser.', 'small'); + addNote(host, 'The next run starts where one ended.', 'small'); + host.body.add( + new Button('Clear saved values', { + variant: 'ghost', + onClick: () => { + const projectId = host.state.project.id; + const had = hasSaved(projectId, asset.id); + clearSaved(projectId, asset.id); + const name = findAsset(host.state.project, asset.id)?.name ?? asset.name; + host.state.setNotice(had ? `Saved values of ${name} cleared` : 'Nothing saved yet'); + }, + }), + ); +} + /** Not in the schema table: the scope lives on the asset itself, not in its property bag. */ const MACHINE_SCOPE: PropertyDef = { key: 'scope', diff --git a/src/editor/saves.ts b/src/editor/saves.ts new file mode 100644 index 00000000..3817cc5d --- /dev/null +++ b/src/editor/saves.ts @@ -0,0 +1,80 @@ +/** + * The saves of persistent Variables assets (#166), kept in this browser per project. Played and + * editor runs write to separate areas, so trying a behavior in the editor leaves the player's save + * alone. Like the editor settings, storage is best effort: a browser that refuses `localStorage` + * plays every run from the declared values. + */ +import type { SaveArea, SavedValues } from '../core/graph'; +import type { PropertyBag } from '../core/model/Properties'; + +export type SaveAreaName = 'play' | 'editor'; + +const AREAS: readonly SaveAreaName[] = ['play', 'editor']; + +type Store = Pick; + +function browserStore(): Store | null { + try { + return window.localStorage; + } catch { + return null; + } +} + +export function saveKey(projectId: string, area: SaveAreaName): string { + return `game-maker.saves.${projectId}.${area}`; +} + +function readArea(store: Store | null, key: string): SavedValues { + try { + const read: unknown = JSON.parse(store?.getItem(key) ?? 'null'); + if (!read || typeof read !== 'object' || Array.isArray(read)) return {}; + const saved: SavedValues = {}; + for (const [assetId, bag] of Object.entries(read)) + if (bag && typeof bag === 'object' && !Array.isArray(bag)) saved[assetId] = bag as PropertyBag; + return saved; + } catch { + return {}; + } +} + +function writeArea(store: Store | null, key: string, saved: SavedValues): void { + try { + if (Object.keys(saved).length === 0) store?.removeItem(key); + else store?.setItem(key, JSON.stringify(saved)); + } catch { + // Full or refused: the run goes on with its values, it just will not find them next time. + } +} + +export function saveArea(projectId: string, area: SaveAreaName, store = browserStore()): SaveArea { + const key = saveKey(projectId, area); + return { + read: () => readArea(store, key), + // Only the assets the run knows are written; one saved by another area or since switched off + // stays as it was until it is cleared. + write: (saved) => writeArea(store, key, { ...readArea(store, key), ...saved }), + }; +} + +/** Whether either area holds anything for the asset – or, without one, for the project. */ +export function hasSaved(projectId: string, assetId?: string, store = browserStore()): boolean { + return AREAS.some((area) => { + const saved = readArea(store, saveKey(projectId, area)); + return assetId === undefined ? Object.keys(saved).length > 0 : assetId in saved; + }); +} + +/** Forgets what both areas saved for one asset, or for the whole project without one. */ +export function clearSaved(projectId: string, assetId?: string, store = browserStore()): void { + for (const area of AREAS) { + const key = saveKey(projectId, area); + if (assetId === undefined) { + writeArea(store, key, {}); + continue; + } + const saved = readArea(store, key); + delete saved[assetId]; + writeArea(store, key, saved); + } +} diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 3c28fa33..5dbb8dc6 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -11,6 +11,9 @@ import { isHandleEnabled, type NodeContext, type PhysicsControl, + restoreSaved, + type SaveArea, + savedSnapshot, type SharedState, startStates, type TraceListener, @@ -107,6 +110,8 @@ export interface GameSystemOptions { * run shares the clock with the previews, and a behavior there must not slow the editor down. */ clock?: TimeScaled | null; + /** Where persistent Variables assets are read from at the start and written to as they change (#166). */ + saves?: SaveArea | null; } export interface TimeScaled { @@ -118,11 +123,19 @@ export function addGameSystems( world: World, project: Project, input: Input, - { shouldRun, audio = SILENT_AUDIO, carried = null, physics = null, clock = null }: GameSystemOptions = {}, + { + shouldRun, + audio = SILENT_AUDIO, + carried = null, + physics = null, + clock = null, + saves = null, + }: GameSystemOptions = {}, ): BehaviorSystem { const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem(); const animations = new AnimationSystem(); - const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio, clock }, shouldRun); + const sources = { collisions, animations, audio, clock, saves }; + const behaviors = new BehaviorSystem(project, input, sources, shouldRun); if (carried) behaviors.carryOver(carried); world.addSystem(collisions); world.addSystem(animations); @@ -137,6 +150,7 @@ export interface EventSources { /** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */ audio?: AudioSink | null; clock?: TimeScaled | null; + saves?: SaveArea | null; } /** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */ @@ -165,6 +179,9 @@ export class BehaviorSystem implements System { private readonly animations: AnimationSystem | null; private readonly audio: AudioSink; private readonly clock: TimeScaled | null; + private readonly saves: SaveArea | null; + /** What was last written to `saves`, so a frame that changed nothing writes nothing. */ + private lastSaved = ''; constructor( private readonly project: Project, @@ -180,6 +197,7 @@ export class BehaviorSystem implements System { this.animations = sources.animations ?? null; this.audio = sources.audio ?? SILENT_AUDIO; this.clock = sources.clock ?? null; + this.saves = sources.saves ?? null; } /** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */ @@ -219,6 +237,9 @@ export class BehaviorSystem implements System { this.carried = null; // Built fresh for every run: a shared variable starts at its declared value each time Play does. this.sharedState = carried ?? buildSharedState(this.project); + // A persistent asset picks up where the last run left it; after a scene swap it already has. + if (!carried && this.saves) restoreSaved(this.project, this.sharedState.variables, this.saves.read()); + this.lastSaved = JSON.stringify(savedSnapshot(this.project, this.sharedState.variables)); this.declared = declaredVariables(this.project); // A `global` machine is entered once for the whole run, before the first instance exists – so // the move is queued here and every instance the first sync creates hears that one entry. @@ -236,11 +257,26 @@ export class BehaviorSystem implements System { this.finish(instance); this.audio.stopOwner(instance.entity.id); } + // After the goodbyes: what `On destroy` writes is the last thing the run did. + this.persist(); this.instances = []; this.pending = []; this.world = null; } + /** + * Writes the persistent values when they differ from the last write. Every frame rather than at + * the end, because a tab closed mid-game has no end to wait for. + */ + private persist(): void { + if (!this.saves) return; + const snapshot = savedSnapshot(this.project, this.sharedState.variables); + const text = JSON.stringify(snapshot); + if (text === this.lastSaved) return; + this.lastSaved = text; + this.saves.write(snapshot); + } + /** * One instance per behavior entity. A switched-off behavior gets its instance and `On start` only when * switched on, which is what lets a hidden screen wake up when shown. @@ -437,6 +473,7 @@ export class BehaviorSystem implements System { } this.reap(world); this.flushStateChanges(); + this.persist(); } /** diff --git a/tests/core/saved-values.test.ts b/tests/core/saved-values.test.ts new file mode 100644 index 00000000..f6540d96 --- /dev/null +++ b/tests/core/saved-values.test.ts @@ -0,0 +1,160 @@ +/** + * A persistent Variables asset keeps its values past the end of the run (#166): the next run starts + * where the last one left off, from whatever area it is handed – played and editor runs get their own. + */ +import { describe, expect, it } from 'vitest'; +import { SetVariablesPersistentCommand } from '../../src/core/commands/assetCommands'; +import { AddVariableCommand, RemoveVariableCommand } from '../../src/core/commands/variableCommands'; +import { restoreSaved, type SaveArea, type SavedValues, VARIABLES_PARAM } from '../../src/core/graph'; +import type { VariablesAsset } from '../../src/core/model/Asset'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { + graphEditor, + makeBehavior, + makeSpriteScene, + makeVariables, + place, + type SceneFixture, + startWorld, +} from '../helpers'; + +/** An area in memory that counts its writes. */ +function memoryArea(): SaveArea & { saved: SavedValues; writes: number } { + const area = { + saved: {} as SavedValues, + writes: 0, + read: () => structuredClone(area.saved), + write: (saved: SavedValues) => { + area.writes++; + area.saved = structuredClone(saved); + }, + }; + return area; +} + +/** One object whose behavior adds one to `score` of the asset on start. */ +function counting(persistent = true) { + const f = makeSpriteScene(); + const shared = makeVariables(f, 'Progress', ['score']); + if (persistent) f.stack.execute(new SetVariablesPersistentCommand(shared.id, true)); + const behavior = makeBehavior(f, 'Counter'); + const editor = graphEditor(f, behavior); + const params = { [VARIABLES_PARAM]: shared.id }; + const start = editor.addNode('OnStart'); + const read = editor.variables(['score'], { params }); + const add = editor.addNode('Add', { b: 1 }); + const write = editor.variables(['score'], { write: true, params }); + editor.wire([ + [start, 'exec', write, 'exec'], + [read, 'score', add, 'a'], + [add, 'result', write, 'score'], + ]); + place(f, behavior.id, { x: 0, y: 0 }); + return { f, shared }; +} + +function run(f: SceneFixture, saves: SaveArea) { + const started = startWorld(f, { behaviors: true, saves }); + started.frame(); + return started; +} + +const scoreOf = (started: ReturnType, id: string) => + started.behaviors!.shared.variables[id]!.score; + +describe('a persistent Variables asset', () => { + it('starts the next run where the last one ended', () => { + const { f, shared } = counting(); + const area = memoryArea(); + expect(scoreOf(run(f, area), shared.id)).toBe(1); + expect(scoreOf(run(f, area), shared.id)).toBe(2); + expect(area.saved).toEqual({ [shared.id]: { score: 2 } }); + }); + + it('keeps played and editor runs apart', () => { + const { f, shared } = counting(); + const played = memoryArea(); + const editor = memoryArea(); + run(f, played); + run(f, played); + expect(scoreOf(run(f, editor), shared.id)).toBe(1); + expect(played.saved[shared.id]!.score).toBe(2); + }); + + it('writes only in a frame that changed something', () => { + const { f } = counting(); + const area = memoryArea(); + const started = run(f, area); + const after = area.writes; + started.frame(); + started.frame(); + expect(after).toBe(1); + expect(area.writes).toBe(1); + }); + + it('starts from the declared value again once the save is gone', () => { + const { f, shared } = counting(); + const area = memoryArea(); + run(f, area); + area.saved = {}; + expect(scoreOf(run(f, area), shared.id)).toBe(1); + }); +}); + +describe('a Variables asset that is not persistent', () => { + it('neither reads nor writes a save', () => { + const { f, shared } = counting(false); + const area = memoryArea(); + area.saved = { [shared.id]: { score: 40 } }; + expect(scoreOf(run(f, area), shared.id)).toBe(1); + expect(area.writes).toBe(0); + }); +}); + +describe('restoring a save', () => { + function project() { + const f = makeSpriteScene(); + const shared = makeVariables(f, 'Progress', ['score', 'lives']); + f.stack.execute(new SetVariablesPersistentCommand(shared.id, true)); + return { f, shared }; + } + + it('follows the declaration as it is now, not as it was saved', () => { + const { f, shared } = project(); + f.stack.execute(new RemoveVariableCommand(shared.id, 'lives')); + f.stack.execute(new AddVariableCommand(shared.id, 'name', 'text')); + const variables = { [shared.id]: { score: 0, name: '' } }; + restoreSaved(f.project, variables, { [shared.id]: { score: '12', lives: 3, name: 7 } }); + // A key no longer declared stays out, and each value takes the kind declared today. + expect(variables[shared.id]).toEqual({ score: 12, name: '7' }); + }); + + it('leaves a key the save does not name at its declared value', () => { + const { f, shared } = project(); + const variables = { [shared.id]: { score: 0, lives: 3 } }; + restoreSaved(f.project, variables, { [shared.id]: { score: 5 } }); + expect(variables[shared.id]).toEqual({ score: 5, lives: 3 }); + }); +}); + +describe('the persistent switch', () => { + it('is one undo step', () => { + const f = makeSpriteScene(); + const shared = makeVariables(f, 'Progress', ['score']); + f.stack.execute(new SetVariablesPersistentCommand(shared.id, true)); + expect(shared.persistent).toBe(true); + f.stack.undo(); + expect(shared.persistent).toBeUndefined(); + }); + + it('survives saving and loading', () => { + const f = makeSpriteScene(); + const shared = makeVariables(f, 'Progress', ['score']); + f.stack.execute(new SetVariablesPersistentCommand(shared.id, true)); + const loaded = deserializeProject(JSON.parse(JSON.stringify(serializeProject(f.project)))); + const asset = loaded.assets.find( + (a): a is VariablesAsset => a.id === shared.id && a.type === 'variables', + ); + expect(asset?.persistent).toBe(true); + }); +}); diff --git a/tests/editor/saves.test.ts b/tests/editor/saves.test.ts new file mode 100644 index 00000000..602c6c00 --- /dev/null +++ b/tests/editor/saves.test.ts @@ -0,0 +1,70 @@ +/** The browser side of #166: one stored entry per project and area, cleared per asset or per project. */ +import { describe, expect, it } from 'vitest'; +import { clearSaved, hasSaved, saveArea, saveKey } from '../../src/editor/saves'; + +function memoryStore() { + const items = new Map(); + return { + items, + getItem: (key: string) => items.get(key) ?? null, + setItem: (key: string, value: string) => void items.set(key, value), + removeItem: (key: string) => void items.delete(key), + }; +} + +describe('save areas', () => { + it('keep projects and areas apart', () => { + const store = memoryStore(); + saveArea('p1', 'play', store).write({ a: { score: 1 } }); + saveArea('p1', 'editor', store).write({ a: { score: 2 } }); + saveArea('p2', 'play', store).write({ a: { score: 3 } }); + expect(saveArea('p1', 'play', store).read()).toEqual({ a: { score: 1 } }); + expect(saveArea('p1', 'editor', store).read()).toEqual({ a: { score: 2 } }); + expect(saveArea('p2', 'play', store).read()).toEqual({ a: { score: 3 } }); + }); + + it('leave an asset the run does not write as it was', () => { + const store = memoryStore(); + const area = saveArea('p1', 'play', store); + area.write({ a: { score: 1 }, b: { coins: 4 } }); + area.write({ a: { score: 2 } }); + expect(area.read()).toEqual({ a: { score: 2 }, b: { coins: 4 } }); + }); + + it('read an unreadable entry as nothing saved', () => { + const store = memoryStore(); + store.setItem(saveKey('p1', 'play'), '{broken'); + expect(saveArea('p1', 'play', store).read()).toEqual({}); + store.setItem(saveKey('p1', 'play'), JSON.stringify({ a: 5, b: { score: 1 } })); + expect(saveArea('p1', 'play', store).read()).toEqual({ b: { score: 1 } }); + }); + + it('keep working without storage', () => { + const area = saveArea('p1', 'play', null); + area.write({ a: { score: 1 } }); + expect(area.read()).toEqual({}); + }); +}); + +describe('clearing saved data', () => { + it('forgets one asset in both areas and keeps the others', () => { + const store = memoryStore(); + saveArea('p1', 'play', store).write({ a: { score: 1 }, b: { coins: 4 } }); + saveArea('p1', 'editor', store).write({ a: { score: 2 } }); + clearSaved('p1', 'a', store); + expect(saveArea('p1', 'play', store).read()).toEqual({ b: { coins: 4 } }); + expect(hasSaved('p1', 'a', store)).toBe(false); + expect(store.items.has(saveKey('p1', 'editor'))).toBe(false); + }); + + it('forgets the whole project and leaves other projects alone', () => { + const store = memoryStore(); + saveArea('p1', 'play', store).write({ a: { score: 1 } }); + saveArea('p1', 'editor', store).write({ a: { score: 2 } }); + saveArea('p2', 'play', store).write({ a: { score: 3 } }); + expect(hasSaved('p1', undefined, store)).toBe(true); + clearSaved('p1', undefined, store); + expect(hasSaved('p1', undefined, store)).toBe(false); + expect(hasSaved('p2', undefined, store)).toBe(true); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 32ca12f0..13881a9b 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -31,6 +31,7 @@ import type { SceneObject } from '../src/core/model/SceneObject'; import { deserializeProject, serializeProject } from '../src/core/store/serialize'; import type { AudioSink } from '../src/runtime/systems/Audio'; import { BehaviorSystem, type TimeScaled } from '../src/runtime/systems/BehaviorSystem'; +import type { SaveArea } from '../src/core/graph/savedValues'; import { readyPhysics } from '../src/runtime/physics/engine'; import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem'; import { CollisionSystem } from '../src/runtime/systems/CollisionSystem'; @@ -299,6 +300,8 @@ export interface WorldOptions { audio?: AudioSink; /** The clock `Set time scale` turns; without one the run is treated like the editor run. */ clock?: TimeScaled; + /** Where persistent Variables assets keep their values; without one nothing outlives the run. */ + saves?: SaveArea; } export interface StartedWorld { @@ -334,6 +337,7 @@ export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): S animations, audio: options.audio, clock: options.clock, + saves: options.saves, }) : null; if (behaviors) world.addSystem(behaviors); diff --git a/tools/e2e/32-scope.mjs b/tools/e2e/32-scope.mjs index 0fa4daeb..e8853924 100644 --- a/tools/e2e/32-scope.mjs +++ b/tools/e2e/32-scope.mjs @@ -4,7 +4,8 @@ import { MAKE_OBJECT } from './lib/scaffold.mjs'; /** * Shared state: a Variables asset holds one value per key for the whole run, and a state machine * declared `global` one current state. The step checks the run (two objects counting into one - * number), that the node offers the asset's declarations, and the switch on the machine. + * number), that the node offers the asset's declarations, the switch on the machine, and a + * persistent asset going on from the last run (#166). * * The step brings its own boxes, behavior, Variables asset and machine; everything goes at the end. */ @@ -147,7 +148,103 @@ export async function scope(t) { ); await shot('03-machine-scope'); - // 32f. Everything this step added goes again. + // 32f. Persistent (#166): the switch on the asset, a second run going on from the first, the + // editor run saving apart, and both ways of clearing. Saves live in this browser, so the + // step starts by forgetting whatever an earlier run of the suite left there. + // 32d pointed the reading node at the behavior; counting on from a save needs it back on the asset. + await mutate(` + const g = __gm.state.project.assets.find((a) => a.id === 'sc-logic').graph; + g.nodes.find((n) => n.id === 'sc-read').params.variablesAsset = 'sc-vars'; + `); + const pid = await json('__gm.state.project.id'); + const saveKey = (area) => `game-maker.saves.${pid}.${area}`; + const saved = (area) => + json(`JSON.parse(localStorage.getItem(${JSON.stringify(saveKey(area))}) ?? 'null')`); + await t.evaluate( + `localStorage.removeItem(${JSON.stringify(saveKey('play'))}); localStorage.removeItem(${JSON.stringify(saveKey('editor'))})`, + ); + await selectAsset('sc-vars'); + const persistentBox = await json(`__gm.inspectorField('Persistent')`); + if (persistentBox) await click(persistentBox.x, persistentBox.y); + await sleep(200); + const kept = await asset('sc-vars'); + const keptNote = await has('Kept between runs, in this browser.'); + check( + 'the Persistent switch on a Variables asset marks it and says what it means', + Boolean(persistentBox) && kept.persistent === true && keptNote, + JSON.stringify({ box: persistentBox, persistent: kept.persistent, note: keptNote }), + ); + await shot('04-persistent'); + + const scoreOfRun = async () => { + await t.play(); + await sleep(600); + const score = (await json('__gm.world()')).shared.variables['sc-vars']?.score; + await stop(); + return score; + }; + const firstRun = await scoreOfRun(); + const secondRun = await scoreOfRun(); + const afterPlay = await saved('play'); + check( + 'the next run goes on where the last one ended, and the save says so', + firstRun === 2 && secondRun === 4 && afterPlay?.['sc-vars']?.score === 4, + JSON.stringify({ firstRun, secondRun, saved: afterPlay }), + ); + const declaredScore = (await asset('sc-vars')).props.score; + check( + 'the project itself keeps the declared start value', + declaredScore === 0, + `props.score = ${JSON.stringify(declaredScore)}`, + ); + + // One box runs in the editor: a run of its own, which must not touch what Play saved. + await t.select({ + kind: 'object', + holderId: await json('__gm.state.project.activeSceneId'), + objectId: 'sc-brain-a', + }); + await sleep(200); + const runBox = await json(`__gm.inspectorField('Run in editor')`); + if (runBox) await click(runBox.x, runBox.y); + await sleep(600); + const editorSave = await saved('editor'); + const playSave = await saved('play'); + if (runBox) await click(runBox.x, runBox.y); + await sleep(200); + check( + 'an editor run saves into an area of its own and leaves the played save alone', + editorSave?.['sc-vars']?.score === 1 && playSave?.['sc-vars']?.score === 4, + JSON.stringify({ box: runBox, editor: editorSave, play: playSave }), + ); + + await selectAsset('sc-vars'); + const clearButton = await t.find('Clear saved values'); + if (clearButton) await click(clearButton.x, clearButton.y); + await sleep(200); + const clearedNotice = await json('__gm.state.notice'); + const clearedBoth = (await saved('play')) === null && (await saved('editor')) === null; + const freshRun = await scoreOfRun(); + check( + '"Clear saved values" forgets both saves, and the next run starts from the declared value', + Boolean(clearButton) && + clearedBoth && + freshRun === 2 && + clearedNotice === 'Saved values of Score cleared', + JSON.stringify({ button: Boolean(clearButton), clearedBoth, freshRun, notice: clearedNotice }), + ); + + await t.clickToolbar('menu'); + await t.clickText('Clear saved data'); + await sleep(200); + const menuCleared = await saved('play'); + check( + '"Clear saved data" in the menu forgets the saves of the whole project', + menuCleared === null && (await json('__gm.state.notice')) === 'Saved data cleared', + JSON.stringify({ play: menuCleared }), + ); + + // 32g. Everything this step added goes again. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index f22e50fa..ab24826a 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -47,13 +47,14 @@ export async function topbar(t) { ); await shot('46-topbar'); - // 46b. The one menu. Four entries, and the separator between what opens a project and what - // changes the one that is open. + // 46b. The one menu, with the separator between what opens a project and what changes the one + // that is open. Clearing the saved data is checked in 32. await clickToolbar('menu'); const rows = await json('__gm.menuRows()'); + const expected = ['New project', 'Open project…', null, 'Project settings…', 'Clear saved data']; check( - 'the menu button opens new project, the project picker and the project settings', - JSON.stringify(rows) === JSON.stringify(['New project', 'Open project…', null, 'Project settings…']), + 'the menu button opens new project, the project picker, the settings and clearing saves', + JSON.stringify(rows) === JSON.stringify(expected), JSON.stringify(rows), );