diff --git a/AGENTS.md b/AGENTS.md index 35b5ca242..47fb53dc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ this step by step for bug fixes. /pooling # Object pooling /rendering # Rendering system /states # Game states (createGameState), inState/onEnter/onExit run conditions, state-scoped entities + /storage # StorageBackend (localStorage, memory) and createPersistentState: typed records kept outside the game /text # MSDF font atlas loading and text rendering /timer # Timer utilities /ui # Retained-mode UI (anchored rect tree layout, canvases, panels, labels, buttons, focus navigation, toggles, sliders, progress bars, dropdowns, layout groups, content size/aspect ratio fitters) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4da9d129..8458ba6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Added +- **storage:** New `@forge-game-engine/forge/storage` module for data that has to outlive the page, such as settings, achievements and unlocked levels. `await createPersistentState(name, defaults, { validators, storage })` loads a named record of numbers, strings and booleans once, takes the default for every field that isn't stored, and stores only the fields you `set`, so changing a default in an update reaches players who never changed that field. `set` and `reset` raise `onChange` and return a promise that settles once the change is stored; writes go out one at a time and in order. Nothing falls back silently: a stored value of the wrong type rejects with `PersistentStateValueError` naming the field, an entry that isn't a JSON object rejects with `PersistentStateFormatError`, and storage failures reject with `StorageError` (`StorageUnavailableError`, `StorageBlockedError`, `StorageFullError`). Records are kept through a `StorageBackend`: `createLocalStorageBackend()` (the default) or `createMemoryStorageBackend()`, or your own. See the new Storage guide and Persistent State demo - **physics:** Continuous collision detection. Register the new `createContinuousCollisionEcsSystem()` directly after `createEulerIntegrationEcsSystem` and a fast dynamic `CircleCollider` body no longer sinks deep into, or passes through, a static collider (polygon, circle or terrain) between two ticks. A wheel coming down from a jump, for example, now stops at the terrain's surface instead of sinking 25-30 units into it in a single tick. Write teleports to `position.local` before `createTransformEcsSystem` runs, or the sweep treats the jump as motion. The sweeps it uses are public too: `sweepCircleCircle`, `sweepCirclePolygon` and `sweepCircleTerrain` return the first `SweepHit` (`point`, `normal`, `t`) of a circle moving from one position to another - **physics:** Collision filtering. `addColliderComponent` takes a `category` (the bits a collider belongs to, default `1`) and a `mask` (the categories it collides with, default `allCollisionCategories`). Two colliders are tested only when each one's category is in the other's mask, so pairs your game would ignore, such as bullets against bullets, never reach the narrow phase - **physics:** `raycast` takes a `mask` option, so a ray only hits colliders whose `category` is in it diff --git a/design/audio-mixer.md b/design/audio-mixer.md index b01e999a2..0907b2696 100644 --- a/design/audio-mixer.md +++ b/design/audio-mixer.md @@ -6,7 +6,7 @@ | **Kind** | Missing feature | | **Found in** | Galactic Journey demo: `src/audio/audio-mixer.ts`, `src/speed/create-speed-sounds.ts`, `src/explosions/create-explosions.ts`, `src/gun/gun.system.ts`, `src/enemy/enemy.system.ts`, `src/music/create-music.ts`, `src/main-menu/create-settings-panel.ts` | | **Engine version at time of writing** | `0.25.8` | -| **Related** | [`demo-findings.md`](./demo-findings.md), [`persistent-preferences.md`](./persistent-preferences.md) | +| **Related** | [`demo-findings.md`](./demo-findings.md) | ## 0. Targeted modules @@ -95,8 +95,8 @@ Howler is removed as a dependency. ### Out of scope - **Saving the player's volume settings.** The mixer exposes the values; a - game stores them with its other settings - ([`persistent-preferences.md`](./persistent-preferences.md)). + game stores them with its other settings in a persistent state + (`createPersistentState` in `@forge-game-engine/forge/storage`). - **Positional (panned or attenuated) audio.** A follow-up once sounds are entity-bound (Phase 2), not needed by the demo. - **Effects on buses** (filters, reverb, compression, ducking). A bus is diff --git a/design/persistent-preferences.md b/design/persistent-preferences.md deleted file mode 100644 index 4deefea4e..000000000 --- a/design/persistent-preferences.md +++ /dev/null @@ -1,542 +0,0 @@ -# Design: Persistent State - -| | | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Status** | Draft, for review | -| **Kind** | Feature | -| **Found in** | Galactic Journey demo: `src/audio/audio-mixer.ts` and `src/graphics/graphics-settings-store.ts` (two copies of the same load/validate/save code) | -| **Engine version at time of writing** | `0.25.8` | -| **Related** | [#567](https://github.com/Forge-Game-Engine/Forge/issues/567) (save/load epic), [`audio-mixer.md`](./audio-mixer.md), [`webgl-context-loss.md`](./webgl-context-loss.md) | - -## 0. Targeted modules - -| Path | Change | Notes | -| --------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/storage/` | **New** | `StorageBackend` (shared with #567's save system), the `localStorage` and memory backends, and `createPersistentState`: typed records kept outside the game | -| `src/index.ts`, `package.json` exports | Modified | New module | -| `documentation-site/docs/docs/storage/` | **New** | Guide: persistent state, writing it into the world, backends, implementing another backend | - ---- - -## 1. Summary - -Games keep state that has to outlive the page: settings such as volumes -and graphics quality, and also achievements, unlocked levels and account -details. Forge has nothing for this, so the demo writes its own, twice. -Its audio mixer and its graphics settings each: - -- pick a `localStorage` key under the game's name, -- parse the stored JSON in a `try`, because storage can be unavailable, -- check each field's type and fall back to its default if it's missing or - wrong (for example after a game update changed the settings), -- write the whole object back in a `try`, because storage can be full or - blocked in a private window, in which case the setting lasts for the - session. - -None of that is specific to the game. Unity has `PlayerPrefs` and Godot -has `ConfigFile` for exactly this, separate from saving a world. This -design adds persistent state to Forge: typed records kept outside the -game, stored through a storage interface that -[#567](https://github.com/Forge-Game-Engine/Forge/issues/567)'s save -system will share, with `localStorage` as its first implementation. A -record lives above the world, not in it, and writes its values into the -world as component values. - ---- - -## 2. Scope - -### In scope - -- `StorageBackend`: the interface every storage implementation satisfies; - `createLocalStorageBackend`, its first implementation, and - `createMemoryStorageBackend`. -- `createPersistentState`: a named record of flat values with defaults, - validation, persistence through a `StorageBackend`, and a change event. -- Writing a record's values into the world as component values. -- Specific errors when storage is unavailable, blocked or full, and when - stored data doesn't match the record. - -### Out of scope - -- **Serializing worlds** (entities and their components). That's - [#567](https://github.com/Forge-Game-Engine/Forge/issues/567), which - stores what it serializes through the same backends. -- **Other backends' implementations.** IndexedDB, a remote endpoint or a - desktop wrapper's file system implement `StorageBackend` later, with - #567 or when a game needs one. #567 is expected to extend the interface - too (listing what's stored, for a load menu), which custom backends then - implement as well; that's acceptable before 1.0. -- **Nested values and migrations.** Values are flat numbers, strings and - booleans. Structured data such as key bindings can be stored as a JSON - string, the way Unity stores input binding overrides in `PlayerPrefs`. A - field that changes meaning gets a new name (open question 1). -- **Syncing across devices.** - ---- - -## 3. How established engines handle this - -- **Unity**: `PlayerPrefs.SetFloat`/`GetFloat(key, default)`, one API - over per-platform storage (the registry, a plist, or IndexedDB on - WebGL). Separate from save games. On WebGL, reads and writes go to - memory synchronously, and reaching IndexedDB is asynchronous. -- **Godot**: `ConfigFile` with sections and keys, saved under `user://`, - read with `get_value(section, key, default)`. On the web, `user://` is - kept in IndexedDB and loaded when the game starts. -- **Bevy**: no built-in. `bevy_pkv` is a key-value store - (`get::(key)`, `set(key, &value)`) meant for settings and saves; in - the browser it uses `localStorage`, chosen to keep its API synchronous. - `bevy-persistent` persists a whole typed resource, which game code - changes through its own API. - -The key-value stores keep only the keys a game set and apply defaults when -reading, so a missing or new field just works, and a default the game -changes in an update reaches every player who never changed it. - ---- - -## 4. Design - -### 4.1 Storage backends - -```ts -/** - * Where persisted strings are kept. Asynchronous, so IndexedDB and remote - * endpoints can implement it as well as `localStorage`. - */ -interface StorageBackend { - /** The string stored under `key`, or `null`. Rejects with a `StorageError` if storage can't be read. */ - get(key: string): Promise; - /** Stores `value` under `key`. Rejects with a `StorageError` if it can't be stored. */ - set(key: string, value: string): Promise; - /** Removes whatever is stored under `key`. Rejects with a `StorageError` if it can't. */ - remove(key: string): Promise; -} - -/** What every backend rejects with when storage fails. The original error is its `cause`. */ -class StorageError extends Error {} -/** There's no storage to use: no `localStorage` in this environment, an unreachable endpoint. */ -class StorageUnavailableError extends StorageError {} -/** Storage exists but is blocked: by the browser's settings, a frame denied storage, private browsing. */ -class StorageBlockedError extends StorageError {} -/** Storage is full. */ -class StorageFullError extends StorageError {} - -function createLocalStorageBackend(): StorageBackend; -/** Keeps values in memory only: for tests, or for a game that chooses to carry on without storage. */ -function createMemoryStorageBackend(): StorageBackend; -``` - -Values are strings, the format every candidate backend can hold -(`localStorage` holds nothing else); callers serialize. Persistent state -is the interface's first user, not its only one: #567's save system -stores serialized worlds through it, so a game that switches backends -changes one argument, not its game code. - -The contract every backend keeps: - -- Every promise it returns settles. A backend that talks to a network - applies its own timeouts, so a hung request can't stall its callers. -- `remove` resolves once nothing is stored under the key, whether or not - anything was. -- Creating a backend touches nothing; only its methods touch storage. The - docs site imports engine modules during its server-side build, where - there's no `window`. - -The `localStorage` backend touches `localStorage` only inside `try` blocks -(even reading the property throws a `SecurityError` where storage is -blocked) and turns each failure into the matching error: no -`localStorage` is `StorageUnavailableError`, a `SecurityError` is -`StorageBlockedError`, and a `QuotaExceededError` is `StorageFullError`. -Anything else is rethrown as it is. It does its work inside the call and -returns a settled promise, so its writes land straight away, as the -demo's do today, and aren't lost if the page closes right after. - -### 4.2 Persistent state - -```ts -type PersistentValue = number | string | boolean; - -interface PersistentState { - /** - * The current values: the stored ones, and defaults for fields that - * aren't stored. Replaced (not mutated) on every change, so read it from - * the record rather than keeping a reference. - */ - readonly values: Readonly; - /** - * Applies `changes` at once and stores them. Resolves once they're - * stored; rejects with the backend's `StorageError` if they can't be. - * @throws `PersistentStateValueError` if a value fails validation; nothing is applied then. - */ - set(changes: Partial): Promise; - /** Makes every value its default again and removes the stored entry. Settles like `set`. */ - reset(): Promise; - /** Raised after `set` or `reset` with the new values. */ - readonly onChange: ParameterizedForgeEvent>; -} - -interface PersistentStateOptions { - /** Per-field checks beyond "same type as the default", e.g. one of a set of strings. */ - validators: { [K in keyof T]?: (value: unknown) => value is T[K] }; - /** Where the record is stored. */ - storage: StorageBackend; -} - -const defaultPersistentStateOptions = { - validators: {}, - storage: createLocalStorageBackend(), -}; - -function createPersistentState( - name: string, - defaults: T, - options: Partial> = {}, -): Promise>; - -/** The stored entry isn't a JSON object: something else wrote it, or it's damaged. */ -class PersistentStateFormatError extends Error {} - -/** A value doesn't have its default's type, or fails its validator. */ -class PersistentStateValueError extends Error { - /** The field the value is for. */ - readonly field: string; - /** The value, as stored or as passed to `set`. */ - readonly value: unknown; -} -``` - -The constraint is written as a mapped type, not -`Record`, so interfaces such as the demo's -`AudioSettings` are accepted. - -- **Loading** happens once, in `createPersistentState`, which resolves - once the stored entry has been read. After that, `values` is read from - memory and `set` applies synchronously, whatever the backend, as Unity's - `PlayerPrefs` and Godot's `user://` behave on the web once their stores - are loaded. A field that isn't stored takes its default, which is how a - game update adds one. A stored field must have its default's type - (numbers must also be finite) and pass its validator; otherwise - `createPersistentState` rejects with `PersistentStateValueError`, naming - the field (DL-2). A stored volume of `-0.1` is an error, not a default. -- **Saving** writes the entry as it was loaded, with the keys `set` since - then applied, as JSON under `name`. Fields this version doesn't know - stay as they were stored, so nothing set in an earlier session, or by a - newer version of the game, is lost. Defaults are never written, so - changing a default in a game update reaches every player who hadn't - changed that field. `reset` empties the entry, which removes it. -- **Write order.** One queue does every write, for `set` and `reset` - alike, with at most one write in flight: it stores the current entry, or - removes it when it's empty. Changes made while a write is in flight go - out together in the next one, and their promises settle with it, so a - slow backend can't apply an older write after a newer one, nor bring - values back after a `reset`. Godot's web build syncs IndexedDB the same - way: one sync at a time, and another after it if anything changed. An - idle queue starts its write without awaiting anything first and never - waits on a timer, so with the `localStorage` backend a change is stored - by the end of the task that made it. -- **Failures are the game's to handle.** Nothing falls back silently: - - `createPersistentState` rejects with the backend's `StorageError` if - the stored entry can't be read, with `PersistentStateFormatError` if it - isn't a JSON object (written by something else, or damaged), and with - `PersistentStateValueError` if a stored field has the wrong type or - fails its validator. - - `set` and `reset` reject with the backend's `StorageError` if the - change can't be stored. The values stay applied in memory, and the - next write that succeeds stores them, since every write stores the - whole entry. The game decides what a failed save means: tell the - player, retry, or carry on for the session. - - A backend that throws instead of rejecting is treated the same. - - A game that wants to carry on without storage creates its record on - `createMemoryStorageBackend()` after catching the error, which makes - that choice visible in its code. - - `set` with a value that fails validation, including `NaN` or - `Infinity`, throws `PersistentStateValueError` before applying - anything: that's a programming error, and `JSON.stringify` would turn - a non-finite number into `null` and quietly lose it on reload. -- **Names.** Use one record per `name`; two would overwrite each other's - writes. Nothing enforces it: a registry of names would make re-creating - a record throw, and docs demos and single-page apps create their games - again on every visit. `localStorage` keys are shared by every page of an - origin, and some hosts serve many games from one origin (itch.io's HTML5 - games, an organization's GitHub Pages sites), so a name carries a game - prefix, as the demo's `galactic-journey.` does. - -### 4.3 The state lives above the world - -A record isn't part of any world, and no system loads or saves it. It's -external state: loaded before a world exists (the demo's graphics quality -picks a render-context option, so it's read before `createGame`), it -outlives the worlds that read it, and its loads and saves are -asynchronous, which systems aren't. - -The game's setup code writes a record's values into the world as -component values, once the world exists and again on every `onChange` -(raised only by changes). Systems read that component like any other. -Changes go through `set`, never through the component, so the record is -the one writer of the fields it mirrors, and nothing keeps a second copy -that drifts from what's stored. State outside any world, such as the audio -mixer's bus volumes in [`audio-mixer.md`](./audio-mixer.md), follows -`onChange` the same way. - -The demo already has this shape: it reads its graphics settings before -`createGame` and adds them as a component (`addGraphicsSettingsComponent`). -With this design: - -```ts -const graphics = await createPersistentState( - 'galactic-journey.graphics', - { quality: 'high' }, - { validators: { quality: isGraphicsQuality } }, -); - -const { world } = createGame('demo-game', { - renderContext: { - maxPixelRatio: graphicsPresets[graphics.values.quality].maxPixelRatio, - }, -}); - -const settings = world.createEntity(); - -world.addComponent(settings, graphicsSettingsId, { ...graphics.values }); -graphics.onChange.registerListener((values) => { - Object.assign( - world.getComponentRequired(settings, graphicsSettingsId), - values, - ); -}); -``` - -Its audio settings become another record: - -```ts -const isVolume = (value: unknown): value is number => - typeof value === 'number' && value >= 0 && value <= 1; - -const audioSettings = await createPersistentState( - 'galactic-journey.audio-settings', - { master: 1, music: 1, sfx: 1, muted: false }, - { validators: { master: isVolume, music: isVolume, sfx: isVolume } }, -); -``` - -Today the demo carries on with defaults when storage is blocked; with this -design it does so explicitly, catching `StorageError` and creating its -records on a memory backend for the session. A `PersistentStateValueError` -(settings an older version stored in another format) it handles by -removing the entry through the backend and creating the record again: the -demo's policy, not the engine's. The graphics "trial" logic (falling back -to a known-good quality after a crash) stays in the game too. - ---- - -## 5. Phases - -### Phase 1: Storage and persistent state - -| # | Task | Size | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | -| 1.1 | `StorageBackend`, the `StorageError` classes, `createLocalStorageBackend` mapping unavailable, blocked and full storage to them, `createMemoryStorageBackend` | S | -| 1.2 | `createPersistentState`: per-field loading, storing only set keys, `reset`, `onChange`, the write queue, promises from `set` and `reset` | M | -| 1.3 | Failures surfaced (rejected and throwing reads and writes, `PersistentStateFormatError`, `PersistentStateValueError`) | S | -| 1.4 | Module wiring (`src/index.ts`, `package.json` exports) | S | -| 1.5 | `storage/` guide, including writing a record into the world; a settings panel in a docs demo; changelog under `#### Added` | M | - -**Definition of done:** a docs demo's settings survive a reload and a -second visit to its page; a stored value of the wrong type rejects with -an error naming its field; a field that isn't stored takes its default, -and changing a default reaches a player who never set it; keys set in an -earlier session survive a write in the next; a test backend that resolves -writes out of order still ends with the latest values stored, and with -nothing stored after a `reset`. - ---- - -## 6. Decision log - -### DL-1: Persistent state separate from world serialization - -**Options.** (a) Typed records now. (b) Wait for #567's world -serialization and store this state as entities. - -**Decision: (a).** - -**Rationale.** Some of this state is read before any world exists (the -demo picks its render quality before creating its render pipeline), it's -small and flat, and it isn't entities. Unity and Godot keep settings -separate from saves for the same reasons. #567's serialized worlds go -through the same backends. - -### DL-2: A stored value of the wrong type is an error - -**Options.** (a) Fall back to the default for that field, as both demo -stores do. (b) Reject with an error naming the field. - -**Decision: (b)**, decided in review. - -**Rationale.** A record holds more than settings: achievements, account -details and other data where quietly replacing a stored value with its -default loses it. A value of the wrong type is a bug, or a change of -format the game has to handle itself (migrating the entry or resetting -it), so it's an error, at `set` as much as at load. A field that isn't -stored at all is different: it takes its default, which is how a game -update adds one. - -### DL-3: Store only what was set - -**Options.** (a) Store the whole record on every change, as the demo -does. (b) Store only the keys that were set. - -**Decision: (b).** - -**Rationale.** (a) pins every default the first time a player changes -anything, so a later change to a default (the demo's starting graphics -quality, say) never reaches them. Every key-value store in §3 works like -(b). - -### DL-4: Flat values only - -**Rationale.** Every setting in the demo and in typical options menus, -and most achievement and profile fields, is a number, string or boolean. -Flat values make the type check against the default sufficient for most -fields, and keep the stored format readable. - -### DL-5: A storage interface now, with `localStorage` its first implementation - -**Options.** (a) Use `localStorage` directly; #567 designs storage later. -(b) A `StorageBackend` interface now, which #567's save system shares, -with `localStorage` its only implementation for now. - -**Decision: (b)**, decided in review. - -**Rationale.** #567 calls for a storage-backend abstraction so a game can -choose `localStorage`, IndexedDB or a remote endpoint without changing -its code. Persistent state is the first code that needs storage, so it -sets the interface, and later backends implement it without touching -persistent state or the games using it (#567 is expected to add listing -to it). Unity's `PlayerPrefs` and Godot's `user://` are likewise one API -over per-platform storage. - -### DL-6: The interface is asynchronous - -**Options.** (a) Synchronous, like Web Storage. (b) Asynchronous. - -**Decision: (b).** - -**Rationale.** IndexedDB and remote endpoints are asynchronous. A -synchronous interface only works over a store that's loaded before the -game starts, which is what Unity's WebGL player and Godot's web build do -in their own startup: Unity loads its IndexedDB file system before the -game's code runs, and Godot loads `user://`. Forge has no startup step of -its own to do that in (`createGame` is synchronous, and games await their -assets before calling it), and #567's saves shouldn't all be loaded into -memory up front, and need a result for each write. Web key-value -libraries such as localForage are asynchronous for the same reasons. -Records, which are small, do the one load themselves: they're awaited at -creation and then read from memory, Unity's and Godot's model one level -up. The cost is `await createPersistentState(...)`, where a game already -awaits its assets. - -### DL-7: Storage failures are errors the game handles - -**Options.** (a) Swallow them: defaults when storage can't be read, -values kept for the session when it can't be written. (b) Specific errors -the game catches. - -**Decision: (b)**, decided in review. - -**Rationale.** Only the game knows what a failure means for it: telling -the player their settings won't be kept, retrying a remote save, or -carrying on without storage. Swallowing hides the failure from the one -place that can act on it. Godot's `ConfigFile.load` and `save` return an -error for the game to check, and Unity's web player threw -`PlayerPrefsException` when a write exceeded its storage. - -### DL-8: The state lives above the world and writes into it - -**Options.** (a) An object passed to the systems that need it, as `Time` -and `InputManager` are. (b) A component that a system loads and writes -back. (c) A record above the world that writes its values into the world -as component values. - -**Decision: (c)**, decided in review. - -**Rationale.** The world can't manage external state: it's needed before -the world exists, it outlives it, and loading and saving are -asynchronous, which systems aren't. (b) would also need change detection, -which Forge doesn't have, to know when to write back. With (c), systems -read components as they read everything else, and the record is the one -writer of the fields it mirrors. `bevy-persistent` is the same idea: a -persisted value that game code changes through its own API. In Bevy that -value is a resource; Forge has no resources, so the world-side copy is a -component. - -### DL-9: Named for what it holds - -**Decision:** persistent state, not player preferences, decided in -review. - -**Rationale.** The same record holds achievements, unlocked levels and -account details as well as settings, and the validation, write and -failure rules above are written for all of them. - ---- - -## 7. Open questions - -1. **Renamed or retyped fields.** A renamed field reads as not stored and - takes its default; a retyped one is an error (DL-2), which the game - handles by rewriting or removing the entry through the backend. A - migration hook would transform the stored entry before it's validated. - [#567](https://github.com/Forge-Game-Engine/Forge/issues/567) plans a - versioning and migration story for saved data. - - (a) No migration hook until a game needs one, then adopt #567's - (proposed). (b) A `migrate(storedObject)` option now. -2. **Should Forge write records into components for the game?** §4.3's - setup code is a few lines per record. - - (a) Not now; it stays setup code, and a helper comes if games keep - writing the same lines (proposed). (b) A helper that adds the - component from `values` and updates it on every `onChange`. - ---- - -## 8. Testing considerations - -- Loading: a wrong-typed field, a non-finite number and a field failing - its validator each reject `createPersistentState` with a - `PersistentStateValueError` naming the field and value; a field that - isn't stored takes its default; extra fields survive the next write; a - rejected or throwing read rejects with the backend's error; malformed - JSON rejects with `PersistentStateFormatError`. -- Saving: defaults are never written; keys set in an earlier session - survive a write in the next; `reset` removes the entry, and a `reset` - while a write is in flight ends with nothing stored; a rejected write - rejects that `set`'s promise, leaves the values applied, and a later - successful write stores them; `onChange` raised; writes are serialized - and the latest state wins against a backend that settles out of order; - with the `localStorage` backend, a change is stored by the end of the - task. -- `set` with an invalid or non-finite value throws - `PersistentStateValueError` and applies nothing; creating a record again - under the same name works. -- The `localStorage` backend: no `localStorage`, a `SecurityError` and a - `QuotaExceededError` reject with `StorageUnavailableError`, - `StorageBlockedError` and `StorageFullError`, with the original as - `cause`; its writes land before the call returns; importing the module - where there's no `window` doesn't throw. -- The memory backend: values round-trip; nothing is shared between two - backends. -- jsdom provides `localStorage`; persistent state tests use the memory - backend, and a stub backend to control timing. - -## 9. Documentation and demo follow-up - -- New `storage/` guide: persistent state and writing it into the world; - `StorageBackend` and its contract; the `localStorage` backend (keys are - shared across the origin, so prefix them); implementing another - backend. -- Demo: the load/validate/save code in the audio mixer and the graphics - settings store is replaced by two awaited `createPersistentState` calls; - the graphics settings component and the mixer's buses are initialized - from `values` and updated from `onChange`. diff --git a/design/webgl-context-loss.md b/design/webgl-context-loss.md index 2137325d6..fc745909d 100644 --- a/design/webgl-context-loss.md +++ b/design/webgl-context-loss.md @@ -1,12 +1,12 @@ # Design: Surviving a Lost WebGL Context -| | | -| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Status** | Draft, for review | -| **Kind** | Feature | -| **Found in** | Galactic Journey demo: `src/graphics/recover-from-context-loss.ts` (saves a lower quality and reloads the page) | -| **Engine version at time of writing** | `0.25.8` | -| **Related** | [`sprite-textures.md`](./sprite-textures.md) and [`shader-uniform-declarations.md`](./shader-uniform-declarations.md) (prerequisites), [`render-resolution.md`](./render-resolution.md), [`persistent-preferences.md`](./persistent-preferences.md) | +| | | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Status** | Draft, for review | +| **Kind** | Feature | +| **Found in** | Galactic Journey demo: `src/graphics/recover-from-context-loss.ts` (saves a lower quality and reloads the page) | +| **Engine version at time of writing** | `0.25.8` | +| **Related** | [`sprite-textures.md`](./sprite-textures.md) and [`shader-uniform-declarations.md`](./shader-uniform-declarations.md) (prerequisites), [`render-resolution.md`](./render-resolution.md) | ## 0. Targeted modules diff --git a/documentation-site/docs/docs/storage/_category_.json b/documentation-site/docs/docs/storage/_category_.json new file mode 100644 index 000000000..5b1cd8285 --- /dev/null +++ b/documentation-site/docs/docs/storage/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Storage", + "position": 14, + "link": { + "type": "doc", + "id": "docs/storage/index" + } +} diff --git a/documentation-site/docs/docs/storage/index.md b/documentation-site/docs/docs/storage/index.md new file mode 100644 index 000000000..3a1f45548 --- /dev/null +++ b/documentation-site/docs/docs/storage/index.md @@ -0,0 +1,17 @@ +--- +sidebar_position: 1 +--- + +# Storage + +The storage module keeps data that has to outlive the page, such as +settings, achievements and unlocked levels. It has two parts: + +- [Persistent state](./persistent-state.md): a named record of numbers, + strings and booleans with defaults, loaded once and stored on every + change. +- [Storage backends](./storage-backends.md): where the stored strings are + kept. The module has a `localStorage` backend and a memory backend, and a + game can implement its own. + +Everything is imported from `@forge-game-engine/forge/storage`. diff --git a/documentation-site/docs/docs/storage/persistent-state.md b/documentation-site/docs/docs/storage/persistent-state.md new file mode 100644 index 000000000..cba48babb --- /dev/null +++ b/documentation-site/docs/docs/storage/persistent-state.md @@ -0,0 +1,163 @@ +--- +sidebar_position: 2 +--- + +# Persistent State + +A persistent state is a named record of flat values (numbers, strings and +booleans) with a default for every field. It's read from storage once, when +it's created, and every change is stored. It exists outside any world, so +it can be read before a world is created and it outlives the worlds that +read it. + +## Creating a persistent state + +[`createPersistentState`](/Forge/docs/api/functions/createPersistentState) +takes the name the record is stored under and the default of every field, +and resolves once the stored entry has been read: + +```ts +import { createPersistentState } from '@forge-game-engine/forge/storage'; + +const audioSettings = await createPersistentState('my-game.audio-settings', { + volume: 1, + muted: false, +}); +``` + +The record is stored in `localStorage` unless you pass another +[backend](./storage-backends.md) as the `storage` option. + +`localStorage` keys are shared by every page of an origin, and some hosts +serve many games from one origin, so prefix the name with your game's +name. Use one record per name: two records with the same name overwrite +each other's stored entry. + +## Reading values + +[`values`](/Forge/docs/api/interfaces/PersistentState#values) holds the +current value of every field: the stored value if there is one, otherwise +the default. + +```ts +const { volume, muted } = audioSettings.values; +``` + +`values` is replaced with a new object on every change, so read it from the +record each time rather than keeping a reference to it. + +## Validating values + +A stored value must have the same type as its default, and a number must be +finite. For anything stricter, pass a validator for the field: + +```ts +const isVolume = (value: unknown): value is number => + typeof value === 'number' && value >= 0 && value <= 1; + +const audioSettings = await createPersistentState( + 'my-game.audio-settings', + { volume: 1, muted: false }, + { validators: { volume: isVolume } }, +); +``` + +The same checks apply to the defaults and to every value passed to `set`. + +## Changing values + +[`set`](/Forge/docs/api/interfaces/PersistentState#set) applies the given +fields to `values` straight away, raises `onChange` and stores them: + +```ts +await audioSettings.set({ volume: 0.5 }); +``` + +The promise resolves once the change is stored. `set` throws a +[`PersistentStateValueError`](/Forge/docs/api/classes/PersistentStateValueError) +without applying anything if a value fails its checks. + +Only fields that were passed to `set` are stored. A field that was never +set keeps taking its default, so a default changed in a later version of +the game applies to every player who never changed that field. Stored +fields that the current defaults don't list are kept. + +Writes are stored one at a time and in order. Changes made while a write is +in flight are stored together by the next write. + +## Resetting to the defaults + +[`reset`](/Forge/docs/api/interfaces/PersistentState#reset) makes every +value its default again, raises `onChange` and removes the stored entry: + +```ts +await audioSettings.reset(); +``` + +## Writing values into the world + +Systems read components, not records. Write a record's values into a +component when the world is created, and again whenever the record changes: + +```ts +const settingsEntity = world.createEntity(); + +world.addComponent(settingsEntity, audioSettingsId, { + ...audioSettings.values, +}); + +audioSettings.onChange.registerListener((values) => { + Object.assign( + world.getComponentRequired(settingsEntity, audioSettingsId), + values, + ); +}); +``` + +[`onChange`](/Forge/docs/api/interfaces/PersistentState#onchange) is raised +after every `set` and `reset`, with the new values. Change the values with +`set`, not by writing the component, so the record is the only writer of +the fields it mirrors and every change is stored. + +## Handling storage failures + +Nothing falls back silently. `createPersistentState` rejects with: + +- the backend's [`StorageError`](/Forge/docs/api/classes/StorageError) if + the stored entry can't be read, for example because storage is blocked; +- a [`PersistentStateFormatError`](/Forge/docs/api/classes/PersistentStateFormatError) + if the stored entry isn't a JSON object; +- a [`PersistentStateValueError`](/Forge/docs/api/classes/PersistentStateValueError) + if a stored value fails its checks. Its `field` and `value` name the + value. + +To carry on without storage when it can't be read, catch the error and +create the record on a memory backend: + +```ts +import { + createMemoryStorageBackend, + createPersistentState, + StorageError, +} from '@forge-game-engine/forge/storage'; + +const defaults = { volume: 1, muted: false }; + +const loadAudioSettings = async () => { + try { + return await createPersistentState('my-game.audio-settings', defaults); + } catch (error) { + if (!(error instanceof StorageError)) { + throw error; + } + + return createPersistentState('my-game.audio-settings', defaults, { + storage: createMemoryStorageBackend(), + }); + } +}; +``` + +`set` and `reset` reject with the backend's `StorageError` if the change +can't be stored. The values stay applied, and the next write that succeeds +stores them. diff --git a/documentation-site/docs/docs/storage/storage-backends.md b/documentation-site/docs/docs/storage/storage-backends.md new file mode 100644 index 000000000..619478f07 --- /dev/null +++ b/documentation-site/docs/docs/storage/storage-backends.md @@ -0,0 +1,108 @@ +--- +sidebar_position: 3 +--- + +# Storage Backends + +A [`StorageBackend`](/Forge/docs/api/interfaces/StorageBackend) stores +strings under keys. Persistent state stores its records through one, and a +game can swap the backend without changing the code that uses the records. + +## Backends in the engine + +- [`createLocalStorageBackend`](/Forge/docs/api/functions/createLocalStorageBackend) + stores strings in the browser's `localStorage`. Each method does its work + before it returns, so a write is stored even if the page closes straight + after. It's the default backend of `createPersistentState`. +- [`createMemoryStorageBackend`](/Forge/docs/api/functions/createMemoryStorageBackend) + keeps strings in memory, so they're lost when the page closes. Each + memory backend has its own strings. Use it in tests, or to carry on + without storage. + +```ts +import { + createMemoryStorageBackend, + createPersistentState, +} from '@forge-game-engine/forge/storage'; + +const settings = await createPersistentState( + 'my-game.settings', + { quality: 'high' }, + { storage: createMemoryStorageBackend() }, +); +``` + +## Storage errors + +A backend rejects with a [`StorageError`](/Forge/docs/api/classes/StorageError) +when storage fails. The error that caused it is its `cause`. The +`localStorage` backend rejects with one of its subclasses: + +- [`StorageUnavailableError`](/Forge/docs/api/classes/StorageUnavailableError): + there's no `localStorage` in this environment. +- [`StorageBlockedError`](/Forge/docs/api/classes/StorageBlockedError): + `localStorage` is blocked, by the browser's settings, a frame that's + denied storage, or private browsing. +- [`StorageFullError`](/Forge/docs/api/classes/StorageFullError): + `localStorage` is full. + +Any other `Error` is rejected with as it is. + +## Implementing a backend + +A backend is an object with three methods, each returning a promise: + +```ts +import { + StorageBackend, + StorageError, + StorageUnavailableError, +} from '@forge-game-engine/forge/storage'; + +const createRemoteStorageBackend = (baseUrl: string): StorageBackend => ({ + get: async (key) => { + const response = await fetchOrThrow(`${baseUrl}/${key}`); + + return response.status === 404 ? null : response.text(); + }, + set: async (key, value) => { + await fetchOrThrow(`${baseUrl}/${key}`, { method: 'PUT', body: value }); + }, + remove: async (key) => { + await fetchOrThrow(`${baseUrl}/${key}`, { method: 'DELETE' }); + }, +}); + +const fetchOrThrow = async ( + url: string, + init?: RequestInit, +): Promise => { + let response: Response; + + try { + response = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) }); + } catch (error) { + throw new StorageUnavailableError(`Unable to reach ${url}.`, error); + } + + if (!response.ok && response.status !== 404) { + throw new StorageError(`${url} responded with ${response.status}.`); + } + + return response; +}; +``` + +Every backend keeps this contract: + +- `get` resolves with the stored string, or `null` if nothing is stored + under the key. +- `remove` resolves once nothing is stored under the key, whether or not + anything was. +- Every promise settles. A backend that talks to a network applies its own + timeouts. +- Failures reject with a `StorageError`, with the original error as its + `cause`. +- Creating the backend touches no storage; only its methods do. The docs + site and other server-side builds import engine modules where there's no + `window`. diff --git a/documentation-site/src/data/demos.ts b/documentation-site/src/data/demos.ts index 5cfca78f5..170c09a3c 100644 --- a/documentation-site/src/data/demos.ts +++ b/documentation-site/src/data/demos.ts @@ -207,6 +207,13 @@ export const demos: Demo[] = [ 'A standalone toggle plus a grouped set that behaves like radio buttons.', categories: ['ui'], }, + { + slug: 'persistent-state', + title: 'Persistent State', + description: + 'A settings panel whose values are stored with createPersistentState and survive a reload.', + categories: ['ui'], + }, { slug: 'ui-slider', title: 'UI Slider', diff --git a/documentation-site/src/pages/demos/persistent-state/_create-game.ts b/documentation-site/src/pages/demos/persistent-state/_create-game.ts new file mode 100644 index 000000000..2630ed36b --- /dev/null +++ b/documentation-site/src/pages/demos/persistent-state/_create-game.ts @@ -0,0 +1,270 @@ +import { + addPositionComponent, + addRotationComponent, + addScaleComponent, + createTransformEcsSystem, + Time, +} from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import defaultFontImageUrl from '@forge-game-engine/forge/fonts/default/default.png'; +import { + MouseInputSource, + registerInputs, +} from '@forge-game-engine/forge/input'; +import { + addSpriteComponent, + Color, + createCamera, + createCameraEcsSystem, + createImageSprite, + createPresentEcsSystem, + createRenderEcsSystem, + getCameraView, + RenderContext, + spriteId, +} from '@forge-game-engine/forge/rendering'; +import { + createTextShapingEcsSystem, + FontAtlas, + FontAtlasCache, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; +import { + createButton, + createLabel, + createSlider, + createToggle, + createUiCanvas, + registerUiSystems, + UiAnchor, +} from '@forge-game-engine/forge/ui'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import { loadSettings, maxSize, minSize } from './_settings'; +import { spinnerId } from './_spinner.component'; +import { createSpinnerEcsSystem } from './_spinner.system'; + +const renderLayers = { + world: 1 << 0, + ui: 1 << 1, +}; + +const controlColor = new Color(0.85, 0.85, 0.88, 1); +const accentColor = new Color(0.35, 0.55, 0.95, 1); +const controlTransition = { + normalColor: controlColor, + hoverColor: new Color(0.78, 0.78, 0.83, 1), + pressedColor: new Color(0.68, 0.68, 0.75, 1), + disabledColor: new Color(0.6, 0.6, 0.6, 0.6), +}; + +async function createBackdrop( + world: EcsWorld, + camera: number, + renderContext: RenderContext, +): Promise { + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const backdropSprite = createImageSprite(whiteImage, renderContext, { + pixelsPerUnit: 1, + layer: renderLayers.world, + }); + backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); + + const { x: width, y: height } = getCameraView( + world, + camera, + renderContext, + ).size; + + backdropSprite.width = width; + backdropSprite.height = height; + + const backdrop = world.createEntity(); + + addPositionComponent(world, backdrop); + addSpriteComponent(world, backdrop, backdropSprite); +} + +function createPointerInput( + world: EcsWorld, + time: Time, + game: Game, +): MouseInputSource { + const inputManager = registerInputs(world, time, {}); + + return new MouseInputSource(inputManager, game.container); +} + +/** + * Builds the persistent state demo: a settings panel whose size slider and + * spin toggle are kept in a persistent state, so they survive a reload. + * The settings are loaded before the game is created, written into the + * square's `SpinnerEcsComponent`, and written again on every change. + * @returns The created game. + */ +export const createPersistentStateGame = async (): Promise => { + const { settings, isStored } = await loadSettings(); + + const { game, world, renderContext, time } = createGame('demo-game'); + + const camera = createCamera(world, { + isStatic: true, + cullingMask: renderLayers.world, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + await createBackdrop(world, camera, renderContext); + + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + + // The square reads its settings from a component, written from the + // record. Changes go through `settings.set`, never the component. + const squareSprite = createImageSprite(whiteImage, renderContext, { + pixelsPerUnit: 1, + layer: renderLayers.world, + }); + squareSprite.width = 120; + squareSprite.height = 120; + squareSprite.tintColor = accentColor; + + const square = world.createEntity(); + + addPositionComponent(world, square, { local: { x: 220, y: 0 } }); + addRotationComponent(world, square); + addScaleComponent(world, square); + addSpriteComponent(world, square, squareSprite); + world.addComponent(square, spinnerId, { ...settings.values, speed: 1.5 }); + + settings.onChange.registerListener((values) => { + Object.assign(world.getComponentRequired(square, spinnerId), values); + }); + + const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); + const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad({ + // Importing the JSON would give its parsed contents, so `new URL` asks + // webpack for its URL instead. + metricsUrl: new URL( + '@forge-game-engine/forge/fonts/default/default.json', + import.meta.url, + ).href, + imageUrl: defaultFontImageUrl, + }); + + registerUiSystems(world, renderContext, time, { + pointerSource: createPointerInput(world, time, game), + }); + + const canvas = createUiCanvas(world, renderContext, { + cullingMask: renderLayers.ui, + referenceResolution: { x: 1920, y: 1080 }, + }); + + const caption = (text: string, y: number, size = 30): void => { + createLabel(world, canvas, { + text, + fontAtlas, + size, + anchor: UiAnchor.topLeft({ x: 600, y: 40 }), + anchoredPosition: { x: 60, y }, + verticalAlign: textVerticalAlignments.middle, + color: Color.white, + category: renderLayers.ui, + }); + }; + + const uiSprite = (tint: Color) => { + const sprite = createImageSprite(whiteImage, renderContext, { + pixelsPerUnit: 1, + layer: renderLayers.ui, + }); + sprite.tintColor = tint; + + return sprite; + }; + + caption('Size', -80); + + const sizeSlider = createSlider(world, canvas, { + trackSprite: uiSprite(controlColor), + handleSprite: uiSprite(Color.white), + handleSize: { x: 40, y: 40 }, + fillSprite: uiSprite(accentColor), + anchor: UiAnchor.topLeft({ x: 400, y: 24 }), + anchoredPosition: { x: 60, y: -130 }, + minValue: minSize, + maxValue: maxSize, + value: settings.values.size, + transition: controlTransition, + }); + + sizeSlider.onValueChanged.registerListener((size) => { + void settings.set({ size }); + }); + + caption('Spin', -220); + + const spinToggle = createToggle(world, canvas, { + sprite: uiSprite(controlColor), + checkmarkSprite: uiSprite(accentColor), + isOn: settings.values.spin, + anchor: UiAnchor.topLeft({ x: 40, y: 40 }), + anchoredPosition: { x: 160, y: -200 }, + transition: controlTransition, + }); + + spinToggle.onValueChanged.registerListener((spin) => { + void settings.set({ spin }); + }); + + const resetButton = createButton(world, canvas, { + anchor: UiAnchor.topLeft({ x: 260, y: 64 }), + anchoredPosition: { x: 60, y: -290 }, + sprite: uiSprite(Color.white), + label: 'Reset', + fontAtlas, + labelSize: 26, + labelColor: new Color(0.12, 0.12, 0.16, 1), + labelCategory: renderLayers.ui, + transition: controlTransition, + }); + + resetButton.onInvoke.registerListener(() => { + void settings.reset(); + }); + + // Writing a control's value directly doesn't raise its `onValueChanged`, + // so the controls follow a reset without storing the defaults. The + // toggle's checkmark only follows `onValueChanged`, so it's shown here. + const checkmarkSprite = world.getComponentRequired( + spinToggle.checkmark, + spriteId, + ); + + settings.onChange.registerListener(({ size, spin }) => { + sizeSlider.slider.value = size; + spinToggle.toggle.isOn = spin; + checkmarkSprite.enabled = spin; + }); + + caption( + isStored + ? 'Reload the page: the settings are kept.' + : 'Storage is unavailable: the settings last for this visit.', + -420, + 24, + ); + + world.addSystem(createSpinnerEcsSystem(time)); + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/persistent-state/_settings.ts b/documentation-site/src/pages/demos/persistent-state/_settings.ts new file mode 100644 index 000000000..884e4cdfe --- /dev/null +++ b/documentation-site/src/pages/demos/persistent-state/_settings.ts @@ -0,0 +1,65 @@ +import { + createMemoryStorageBackend, + createLocalStorageBackend, + createPersistentState, + PersistentState, + PersistentStateValueError, + StorageError, +} from '@forge-game-engine/forge/storage'; + +export interface DemoSettings { + size: number; + spin: boolean; +} + +export const minSize = 0.5; +export const maxSize = 2; + +// `localStorage` keys are shared by every page of the docs site, so the +// name carries a prefix. +const settingsName = 'forge-docs.persistent-state-demo'; + +const defaultSettings: DemoSettings = { size: 1, spin: true }; + +const isSize = (value: unknown): value is number => + typeof value === 'number' && value >= minSize && value <= maxSize; + +const createSettings = ( + storage = createLocalStorageBackend(), +): Promise> => + createPersistentState(settingsName, defaultSettings, { + validators: { size: isSize }, + storage, + }); + +export interface LoadedSettings { + settings: PersistentState; + isStored: boolean; +} + +/** + * Loads the demo's settings from `localStorage`. Stored settings that don't + * pass validation are removed and the defaults are used. When storage can't + * be read, the settings are kept in memory for this visit. + * @returns The settings, and whether they're kept in `localStorage`. + */ +export const loadSettings = async (): Promise => { + try { + return { settings: await createSettings(), isStored: true }; + } catch (error) { + if (error instanceof PersistentStateValueError) { + await createLocalStorageBackend().remove(settingsName); + + return { settings: await createSettings(), isStored: true }; + } + + if (error instanceof StorageError) { + return { + settings: await createSettings(createMemoryStorageBackend()), + isStored: false, + }; + } + + throw error; + } +}; diff --git a/documentation-site/src/pages/demos/persistent-state/_spinner.component.ts b/documentation-site/src/pages/demos/persistent-state/_spinner.component.ts new file mode 100644 index 000000000..9e4701227 --- /dev/null +++ b/documentation-site/src/pages/demos/persistent-state/_spinner.component.ts @@ -0,0 +1,14 @@ +import { createComponentId } from '@forge-game-engine/forge/ecs'; + +/** + * The square's settings, written from the persistent settings record when + * the world is created and whenever the record changes. + */ +export interface SpinnerEcsComponent { + size: number; + spin: boolean; + /** Radians per second while `spin` is on. */ + speed: number; +} + +export const spinnerId = createComponentId('spinner'); diff --git a/documentation-site/src/pages/demos/persistent-state/_spinner.system.ts b/documentation-site/src/pages/demos/persistent-state/_spinner.system.ts new file mode 100644 index 000000000..f23f86915 --- /dev/null +++ b/documentation-site/src/pages/demos/persistent-state/_spinner.system.ts @@ -0,0 +1,33 @@ +import { + RotationEcsComponent, + rotationId, + ScaleEcsComponent, + scaleId, + Time, +} from '@forge-game-engine/forge/common'; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { SpinnerEcsComponent, spinnerId } from './_spinner.component'; + +/** + * Sizes the square from its settings and turns it while `spin` is on. + */ +export const createSpinnerEcsSystem = ( + time: Time, +): EcsSystem< + [SpinnerEcsComponent, ScaleEcsComponent, RotationEcsComponent] +> => ({ + name: 'spinner', + query: [spinnerId, scaleId, rotationId], + update: (_world, { components: [spinners, scales, rotations] }) => { + for (let i = 0; i < spinners.length; i++) { + const { size, spin, speed } = spinners[i]; + + scales[i].local.x = size; + scales[i].local.y = size; + + if (spin) { + rotations[i].local += speed * time.deltaTimeInSeconds; + } + } + }, +}); diff --git a/documentation-site/src/pages/demos/persistent-state/index.tsx b/documentation-site/src/pages/demos/persistent-state/index.tsx new file mode 100644 index 000000000..057995876 --- /dev/null +++ b/documentation-site/src/pages/demos/persistent-state/index.tsx @@ -0,0 +1,51 @@ +import React, { JSX } from 'react'; +import { createPersistentStateGame } from './_create-game'; +import gameCode from '!!raw-loader!./_create-game'; +import settingsCode from '!!raw-loader!./_settings'; +import spinnerComponentCode from '!!raw-loader!./_spinner.component'; +import spinnerSystemCode from '!!raw-loader!./_spinner.system'; + +import { Demo } from '@site/src/components/Demo'; +import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; + +const badgeStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 20, + height: 20, + borderRadius: '50%', + backgroundColor: 'var(--ifm-color-emphasis-300)', + fontSize: 12, +}; + +export default function PersistentState(): JSX.Element { + return ( + + + + } + text="Change the settings, then reload the page." + /> + } + codeFiles={[ + { name: 'game.ts', content: gameCode }, + { name: 'settings.ts', content: settingsCode }, + { name: 'spinner.component.ts', content: spinnerComponentCode }, + { name: 'spinner.system.ts', content: spinnerSystemCode }, + ]} + /> + ); +} diff --git a/package.json b/package.json index 0aa04007c..5f8294e7c 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,12 @@ "default": "./dist/states/index.js" } }, + "./storage": { + "import": { + "types": "./dist/storage/index.d.ts", + "default": "./dist/storage/index.js" + } + }, "./text": { "import": { "types": "./dist/text/index.d.ts", diff --git a/src/index.ts b/src/index.ts index b10f94576..a2c32f938 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,3 +15,4 @@ export * from './ui/index.js'; export * from './utilities/index.js'; export * from './finite-state-machine/index.js'; export * from './states/index.js'; +export * from './storage/index.js'; diff --git a/src/storage/create-local-storage-backend.test.ts b/src/storage/create-local-storage-backend.test.ts new file mode 100644 index 000000000..9d6cffa29 --- /dev/null +++ b/src/storage/create-local-storage-backend.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createLocalStorageBackend } from './create-local-storage-backend'; +import { + StorageBlockedError, + StorageError, + StorageFullError, + StorageUnavailableError, +} from './storage-errors'; + +const namedError = (name: string): Error => { + const error = new Error(name); + + error.name = name; + + return error; +}; + +describe('createLocalStorageBackend', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('round-trips and removes values in localStorage', async () => { + const backend = createLocalStorageBackend(); + + await backend.set('game.key', 'value'); + + expect(localStorage.getItem('game.key')).toBe('value'); + await expect(backend.get('game.key')).resolves.toBe('value'); + + await backend.remove('game.key'); + + await expect(backend.get('game.key')).resolves.toBeNull(); + }); + + it('stores a value before set returns', () => { + const backend = createLocalStorageBackend(); + + void backend.set('game.key', 'value'); + + expect(localStorage.getItem('game.key')).toBe('value'); + }); + + it('touches nothing when created', () => { + vi.stubGlobal('localStorage', undefined); + + expect(() => createLocalStorageBackend()).not.toThrow(); + }); + + it('rejects with StorageUnavailableError when there is no localStorage', async () => { + vi.stubGlobal('localStorage', undefined); + const backend = createLocalStorageBackend(); + + await expect(backend.get('key')).rejects.toBeInstanceOf( + StorageUnavailableError, + ); + await expect(backend.set('key', 'value')).rejects.toBeInstanceOf( + StorageUnavailableError, + ); + await expect(backend.remove('key')).rejects.toBeInstanceOf( + StorageUnavailableError, + ); + }); + + it('rejects with StorageBlockedError when reading localStorage throws a SecurityError', async () => { + const securityError = namedError('SecurityError'); + + vi.spyOn(globalThis, 'localStorage', 'get').mockImplementation(() => { + throw securityError; + }); + const backend = createLocalStorageBackend(); + + const error: unknown = await backend.get('key').catch((e: unknown) => e); + + expect(error).toBeInstanceOf(StorageBlockedError); + expect((error as StorageBlockedError).cause).toBe(securityError); + }); + + it.each(['QuotaExceededError', 'NS_ERROR_DOM_QUOTA_REACHED'])( + 'rejects with StorageFullError when setItem throws a %s', + async (name) => { + const quotaError = namedError(name); + + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw quotaError; + }); + const backend = createLocalStorageBackend(); + + const error: unknown = await backend + .set('key', 'value') + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(StorageFullError); + expect((error as StorageFullError).cause).toBe(quotaError); + }, + ); + + it('rejects with any other error as it is', async () => { + const otherError = new TypeError('something else'); + + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw otherError; + }); + const backend = createLocalStorageBackend(); + + await expect(backend.get('key')).rejects.toBe(otherError); + }); + + it('rejects with a StorageError caused by a thrown value that is not an Error', async () => { + const thrown = 'not an error'; + + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + // The test is about a thrown value that isn't an Error. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw thrown; + }); + const backend = createLocalStorageBackend(); + + const error: unknown = await backend.get('key').catch((e: unknown) => e); + + expect(error).toBeInstanceOf(StorageError); + expect((error as StorageError).cause).toBe(thrown); + }); +}); diff --git a/src/storage/create-local-storage-backend.ts b/src/storage/create-local-storage-backend.ts new file mode 100644 index 000000000..eb6873706 --- /dev/null +++ b/src/storage/create-local-storage-backend.ts @@ -0,0 +1,107 @@ +import { StorageBackend } from './storage-backend.js'; +import { + StorageBlockedError, + StorageError, + StorageFullError, + StorageUnavailableError, +} from './storage-errors.js'; + +// Chrome, Safari and current Firefox name the error `QuotaExceededError`; +// older Firefox versions name it `NS_ERROR_DOM_QUOTA_REACHED`. +const quotaErrorNames = new Set([ + 'QuotaExceededError', + 'NS_ERROR_DOM_QUOTA_REACHED', +]); + +// Browsers throw `DOMException`s, which aren't `Error` instances in every +// environment, so errors are recognized by name. +const hasErrorName = (error: unknown, names: Set): boolean => + typeof error === 'object' && + error !== null && + 'name' in error && + typeof error.name === 'string' && + names.has(error.name); + +const securityErrorNames = new Set(['SecurityError']); + +/** + * Turns an error thrown by `localStorage` into the matching `StorageError`. + * Any other `Error` is returned as it is, and a thrown value that isn't an + * `Error` becomes the `cause` of a `StorageError`, so the backend always + * rejects with an `Error`. + */ +const toStorageError = (error: unknown, action: string): Error => { + if (hasErrorName(error, securityErrorNames)) { + return new StorageBlockedError( + `Unable to ${action}: localStorage is blocked.`, + error, + ); + } + + if (hasErrorName(error, quotaErrorNames)) { + return new StorageFullError( + `Unable to ${action}: localStorage is full.`, + error, + ); + } + + if (error instanceof Error) { + return error; + } + + return new StorageError(`Unable to ${action}.`, error); +}; + +/** + * Runs `operation` against `localStorage` and returns its result as a + * settled promise. Reading the `localStorage` property itself throws a + * `SecurityError` where storage is blocked, so it's read here too. + */ +const withLocalStorage = ( + action: string, + operation: (storage: Storage) => T, +): Promise => { + try { + const storage = (globalThis as { localStorage?: Storage | null }) + .localStorage; + + if (!storage) { + throw new StorageUnavailableError( + `Unable to ${action}: there's no localStorage in this environment.`, + ); + } + + return Promise.resolve(operation(storage)); + } catch (error) { + return Promise.reject(toStorageError(error, action)); + } +}; + +/** + * Creates a backend that stores values in the browser's `localStorage`. + * Every method does its work before it returns, so a write is stored even + * if the page closes straight after. `localStorage` keys are shared by + * every page of an origin, so prefix keys with the game's name. + * + * Creating the backend doesn't touch `localStorage`, so it can be created + * where there's no `window`. Its methods reject with a + * `StorageUnavailableError` when there's no `localStorage`, a + * `StorageBlockedError` when it's blocked (a `SecurityError`), and a + * `StorageFullError` when it's full (a `QuotaExceededError`). Any other + * `Error` is rejected with as it is. + * @returns The `localStorage` backend. + */ +export function createLocalStorageBackend(): StorageBackend { + return { + get: (key) => + withLocalStorage(`read "${key}"`, (storage) => storage.getItem(key)), + set: (key, value) => + withLocalStorage(`store "${key}"`, (storage) => { + storage.setItem(key, value); + }), + remove: (key) => + withLocalStorage(`remove "${key}"`, (storage) => { + storage.removeItem(key); + }), + }; +} diff --git a/src/storage/create-memory-storage-backend.test.ts b/src/storage/create-memory-storage-backend.test.ts new file mode 100644 index 000000000..a7fd231ad --- /dev/null +++ b/src/storage/create-memory-storage-backend.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { createMemoryStorageBackend } from './create-memory-storage-backend'; + +describe('createMemoryStorageBackend', () => { + it('returns null for a key that was never stored', async () => { + const backend = createMemoryStorageBackend(); + + await expect(backend.get('missing')).resolves.toBeNull(); + }); + + it('round-trips a stored value', async () => { + const backend = createMemoryStorageBackend(); + + await backend.set('key', 'value'); + + await expect(backend.get('key')).resolves.toBe('value'); + }); + + it('removes a stored value, and resolves when nothing was stored', async () => { + const backend = createMemoryStorageBackend(); + + await backend.set('key', 'value'); + await backend.remove('key'); + + await expect(backend.get('key')).resolves.toBeNull(); + await expect(backend.remove('missing')).resolves.toBeUndefined(); + }); + + it('shares nothing between two backends', async () => { + const first = createMemoryStorageBackend(); + const second = createMemoryStorageBackend(); + + await first.set('key', 'value'); + + await expect(second.get('key')).resolves.toBeNull(); + }); +}); diff --git a/src/storage/create-memory-storage-backend.ts b/src/storage/create-memory-storage-backend.ts new file mode 100644 index 000000000..dd938189b --- /dev/null +++ b/src/storage/create-memory-storage-backend.ts @@ -0,0 +1,25 @@ +import { StorageBackend } from './storage-backend.js'; + +/** + * Creates a backend that keeps values in memory only, so they're lost when + * the page closes. For tests, or for a game that chooses to carry on + * without storage after a `StorageError`. Each backend has its own values. + * @returns The memory storage backend. + */ +export function createMemoryStorageBackend(): StorageBackend { + const values = new Map(); + + return { + get: (key) => Promise.resolve(values.get(key) ?? null), + set: (key, value) => { + values.set(key, value); + + return Promise.resolve(); + }, + remove: (key) => { + values.delete(key); + + return Promise.resolve(); + }, + }; +} diff --git a/src/storage/create-persistent-state.test.ts b/src/storage/create-persistent-state.test.ts new file mode 100644 index 000000000..f52ecb6ce --- /dev/null +++ b/src/storage/create-persistent-state.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createLocalStorageBackend } from './create-local-storage-backend'; +import { createMemoryStorageBackend } from './create-memory-storage-backend'; +import { createPersistentState } from './create-persistent-state'; +import { + PersistentStateFormatError, + PersistentStateValueError, +} from './persistent-state-errors'; +import { StorageBackend } from './storage-backend'; +import { StorageFullError } from './storage-errors'; + +interface AudioSettings { + volume: number; + muted: boolean; + quality: string; +} + +const defaults: AudioSettings = { volume: 1, muted: false, quality: 'high' }; +const name = 'test-game.settings'; + +const isVolume = (value: unknown): value is number => + typeof value === 'number' && value >= 0 && value <= 1; + +const backendWith = async (stored?: unknown): Promise => { + const backend = createMemoryStorageBackend(); + + if (stored !== undefined) { + await backend.set(name, JSON.stringify(stored)); + } + + return backend; +}; + +const storedEntry = async (backend: StorageBackend): Promise => { + const stored = await backend.get(name); + + return stored === null ? null : JSON.parse(stored); +}; + +const valueError = async ( + promise: Promise, +): Promise => { + const error: unknown = await promise.catch((e: unknown) => e); + + expect(error).toBeInstanceOf(PersistentStateValueError); + + return error as PersistentStateValueError; +}; + +interface ControlledWrite { + kind: 'set' | 'remove'; + value: string | null; + resolve: () => void; + reject: (error: unknown) => void; +} + +/** + * A backend whose writes settle only when the test settles them, and which + * keeps whatever the last-settled write stored. + */ +const createControlledBackend = (): { + backend: StorageBackend; + writes: ControlledWrite[]; + stored: () => string | null; +} => { + const writes: ControlledWrite[] = []; + let stored: string | null = null; + + const write = (kind: 'set' | 'remove', value: string | null): Promise => + new Promise((resolve, reject) => { + writes.push({ + kind, + value, + resolve: () => { + stored = value; + resolve(); + }, + reject, + }); + }); + + return { + backend: { + get: () => Promise.resolve(stored), + set: (_key, value) => write('set', value), + remove: () => write('remove', null), + }, + writes, + stored: () => stored, + }; +}; + +describe('createPersistentState', () => { + describe('loading', () => { + it('takes the defaults when nothing is stored', async () => { + const state = await createPersistentState(name, defaults, { + storage: await backendWith(), + }); + + expect(state.values).toEqual(defaults); + expect(state.name).toBe(name); + }); + + it('takes stored fields, and defaults for fields that are not stored', async () => { + const state = await createPersistentState(name, defaults, { + storage: await backendWith({ volume: 0.5 }), + }); + + expect(state.values).toEqual({ ...defaults, volume: 0.5 }); + }); + + it.each([ + ['a wrong type', { volume: 'loud' }, 'volume', 'loud'], + ['a non-finite number', { volume: null }, 'volume', null], + ['a failing validator', { volume: -0.1 }, 'volume', -0.1], + ])( + 'rejects a stored field with %s, naming the field and value', + async (_, stored, field, value) => { + const error = await valueError( + createPersistentState(name, defaults, { + storage: await backendWith(stored), + validators: { volume: isVolume }, + }), + ); + + expect(error.field).toBe(field); + expect(error.value).toBe(value); + expect(error.stateName).toBe(name); + }, + ); + + it('rejects a default that fails its validator', async () => { + const error = await valueError( + createPersistentState( + name, + { ...defaults, volume: 2 }, + { storage: await backendWith(), validators: { volume: isVolume } }, + ), + ); + + expect(error.field).toBe('volume'); + }); + + it('rejects a non-finite default', async () => { + const error = await valueError( + createPersistentState( + name, + { ...defaults, volume: Number.NaN }, + { storage: await backendWith() }, + ), + ); + + expect(error.field).toBe('volume'); + }); + + it.each(['not json', '[1, 2]', '42', 'null'])( + 'rejects an entry that is not a JSON object (%s) with PersistentStateFormatError', + async (stored) => { + const backend = createMemoryStorageBackend(); + + await backend.set(name, stored); + + await expect( + createPersistentState(name, defaults, { storage: backend }), + ).rejects.toBeInstanceOf(PersistentStateFormatError); + }, + ); + + it('rejects with the error of a read that rejects', async () => { + const readError = new Error('read failed'); + const backend = createMemoryStorageBackend(); + + backend.get = () => Promise.reject(readError); + + await expect( + createPersistentState(name, defaults, { storage: backend }), + ).rejects.toBe(readError); + }); + + it('rejects with the error of a read that throws', async () => { + const readError = new Error('read failed'); + const backend = createMemoryStorageBackend(); + + backend.get = () => { + throw readError; + }; + + await expect( + createPersistentState(name, defaults, { storage: backend }), + ).rejects.toBe(readError); + }); + + it('can be created again under the same name', async () => { + const storage = await backendWith(); + const first = await createPersistentState(name, defaults, { storage }); + + await first.set({ volume: 0.25 }); + + const second = await createPersistentState(name, defaults, { storage }); + + expect(second.values.volume).toBe(0.25); + }); + }); + + describe('saving', () => { + it('stores only the fields that were set', async () => { + const storage = await backendWith(); + const state = await createPersistentState(name, defaults, { storage }); + + await state.set({ muted: true }); + + expect(await storedEntry(storage)).toEqual({ muted: true }); + }); + + it('keeps fields stored by an earlier session, including unknown ones', async () => { + const storage = await backendWith({ volume: 0.5, future: 'kept' }); + const state = await createPersistentState(name, defaults, { storage }); + + await state.set({ muted: true }); + + expect(await storedEntry(storage)).toEqual({ + volume: 0.5, + future: 'kept', + muted: true, + }); + }); + + it('gives a changed default to a field that was never set', async () => { + const storage = await backendWith(); + const first = await createPersistentState(name, defaults, { storage }); + + await first.set({ muted: true }); + + const updated = await createPersistentState( + name, + { ...defaults, volume: 0.8 }, + { storage }, + ); + + expect(updated.values).toEqual({ ...defaults, volume: 0.8, muted: true }); + }); + + it('replaces values on every change and raises onChange with them', async () => { + const state = await createPersistentState(name, defaults, { + storage: await backendWith(), + }); + const before = state.values; + const listener = vi.fn(); + + state.onChange.registerListener(listener); + const write = state.set({ volume: 0.5 }); + + expect(state.values).not.toBe(before); + expect(before.volume).toBe(1); + expect(Object.isFrozen(state.values)).toBe(true); + expect(listener).toHaveBeenCalledWith({ ...defaults, volume: 0.5 }); + + await write; + }); + + it('throws PersistentStateValueError for an invalid value, applying nothing', async () => { + const storage = await backendWith(); + const state = await createPersistentState(name, defaults, { + storage, + validators: { volume: isVolume }, + }); + const listener = vi.fn(); + + state.onChange.registerListener(listener); + + for (const changes of [ + { muted: false, volume: 2 }, + { volume: Number.POSITIVE_INFINITY }, + { volume: Number.NaN }, + { quality: 3 as unknown as string }, + { constructor: 1 } as Partial, + ]) { + expect(() => state.set(changes)).toThrow(PersistentStateValueError); + } + + expect(state.values).toEqual(defaults); + expect(listener).not.toHaveBeenCalled(); + expect(await storage.get(name)).toBeNull(); + }); + + it('reset makes every value its default and removes the entry', async () => { + const storage = await backendWith({ volume: 0.5 }); + const state = await createPersistentState(name, defaults, { storage }); + const listener = vi.fn(); + + state.onChange.registerListener(listener); + await state.reset(); + + expect(state.values).toEqual(defaults); + expect(listener).toHaveBeenCalledWith(defaults); + expect(await storage.get(name)).toBeNull(); + }); + + it('stores a change with the localStorage backend before set returns', async () => { + localStorage.clear(); + const state = await createPersistentState(name, defaults, { + storage: createLocalStorageBackend(), + }); + + const write = state.set({ volume: 0.5 }); + + expect(localStorage.getItem(name)).toBe('{"volume":0.5}'); + await write; + localStorage.clear(); + }); + }); + + describe('the write queue', () => { + it('has at most one write in flight, and sends changes made meanwhile together in the next', async () => { + const { backend, writes, stored } = createControlledBackend(); + const state = await createPersistentState(name, defaults, { + storage: backend, + }); + + const first = state.set({ volume: 0.1 }); + const second = state.set({ volume: 0.2 }); + const third = state.set({ muted: true }); + + expect(writes).toHaveLength(1); + + writes[0].resolve(); + await first; + + expect(writes).toHaveLength(2); + expect(JSON.parse(writes[1].value ?? '')).toEqual({ + volume: 0.2, + muted: true, + }); + + writes[1].resolve(); + await Promise.all([second, third]); + + expect(JSON.parse(stored() ?? '')).toEqual({ volume: 0.2, muted: true }); + }); + + it('ends with the latest values stored when the backend settles writes out of order', async () => { + let stored: string | null = null; + let delay = 30; + const backend: StorageBackend = { + get: () => Promise.resolve(stored), + // Each write settles sooner than the one before it, so without the + // queue an older write would land last. + set: (_key, value) => + new Promise((resolve) => { + delay -= 10; + setTimeout(() => { + stored = value; + resolve(); + }, delay); + }), + remove: () => Promise.resolve(), + }; + const state = await createPersistentState(name, defaults, { + storage: backend, + }); + + await Promise.all([ + state.set({ quality: 'low' }), + state.set({ quality: 'medium' }), + state.set({ quality: 'ultra' }), + ]); + + expect(JSON.parse(stored ?? '')).toEqual({ quality: 'ultra' }); + }); + + it('ends with nothing stored after a reset while a write is in flight', async () => { + const { backend, writes, stored } = createControlledBackend(); + const state = await createPersistentState(name, defaults, { + storage: backend, + }); + + const write = state.set({ volume: 0.1 }); + const reset = state.reset(); + + writes[0].resolve(); + await write; + + expect(writes[1].kind).toBe('remove'); + + writes[1].resolve(); + await reset; + + expect(stored()).toBeNull(); + }); + + it('rejects a failed write, keeps the values, and stores them with the next write', async () => { + const { backend, writes, stored } = createControlledBackend(); + const state = await createPersistentState(name, defaults, { + storage: backend, + }); + const fullError = new StorageFullError('full'); + + const failed = state.set({ volume: 0.1 }); + + writes[0].reject(fullError); + await expect(failed).rejects.toBe(fullError); + + expect(state.values).toEqual({ ...defaults, volume: 0.1 }); + + const next = state.set({ muted: true }); + + writes[1].resolve(); + await next; + + expect(JSON.parse(stored() ?? '')).toEqual({ volume: 0.1, muted: true }); + }); + + it('rejects every change batched into a write that fails', async () => { + const { backend, writes } = createControlledBackend(); + const state = await createPersistentState(name, defaults, { + storage: backend, + }); + const fullError = new StorageFullError('full'); + + const first = state.set({ volume: 0.1 }); + const second = state.set({ volume: 0.2 }); + const third = state.set({ volume: 0.3 }); + + writes[0].resolve(); + await first; + writes[1].reject(fullError); + + await expect(second).rejects.toBe(fullError); + await expect(third).rejects.toBe(fullError); + }); + + it('treats a write that throws like one that rejects', async () => { + const writeError = new Error('write failed'); + const storage = createMemoryStorageBackend(); + const state = await createPersistentState(name, defaults, { storage }); + + storage.set = () => { + throw writeError; + }; + + await expect(state.set({ volume: 0.5 })).rejects.toBe(writeError); + expect(state.values.volume).toBe(0.5); + }); + }); +}); diff --git a/src/storage/create-persistent-state.ts b/src/storage/create-persistent-state.ts new file mode 100644 index 000000000..1d885d753 --- /dev/null +++ b/src/storage/create-persistent-state.ts @@ -0,0 +1,302 @@ +import { ParameterizedForgeEvent } from '../events/index.js'; +import { createLocalStorageBackend } from './create-local-storage-backend.js'; +import { + PersistentStateFormatError, + PersistentStateValueError, +} from './persistent-state-errors.js'; +import { StorageBackend } from './storage-backend.js'; + +/** + * A value a persistent state can hold. Structured data can be stored as a + * JSON string. + */ +export type PersistentValue = number | string | boolean; + +/** + * The shape of a persistent state's values: an object whose every field is + * a {@link PersistentValue}. Written as a mapped type so interfaces are + * accepted as well as type aliases. + */ +export type PersistentValues = { [K in keyof T]: PersistentValue }; + +/** + * Checks a value beyond "same type as the default", for example that a + * number is in a range or a string is one of a set. + */ +export type PersistentValueValidator = ( + value: unknown, +) => value is TValue; + +/** + * A named record of flat values kept outside the game, loaded once by + * {@link createPersistentState} and stored through a `StorageBackend` on + * every change. + */ +export interface PersistentState> { + /** The name the record is stored under. */ + readonly name: string; + + /** + * The current values: the stored ones, and defaults for fields that + * aren't stored. Replaced, not mutated, on every change, so read it from + * the record rather than keeping a reference. + */ + readonly values: Readonly; + + /** Raised after every `set` or `reset`, with the new values. */ + readonly onChange: ParameterizedForgeEvent>; + + /** + * Applies `changes` to `values` at once, raises `onChange` and stores + * the changed fields. If storing fails, the changes stay applied, and + * the next write that succeeds stores them. + * @param changes - The fields to change. + * @returns A promise that resolves once the changes are stored. Rejects + * with the backend's error if they can't be. + * @throws `PersistentStateValueError` if a value doesn't have its + * default's type, isn't a finite number, or fails its validator. Nothing + * is applied then. + */ + set(changes: Partial): Promise; + + /** + * Makes every value its default again, raises `onChange` and removes the + * stored entry. + * @returns A promise that resolves once the entry is removed. Rejects + * with the backend's error if it can't be. + */ + reset(): Promise; +} + +/** + * Options for {@link createPersistentState}. + */ +export interface PersistentStateOptions> { + /** + * Per-field checks beyond "same type as the default". A stored value or + * a value passed to `set` that fails its field's validator is an error. + */ + validators: { [K in keyof T]?: PersistentValueValidator }; + + /** Where the record is stored. Defaults to `localStorage`. */ + storage: StorageBackend; +} + +const defaultPersistentStateOptions = { + validators: {}, + storage: createLocalStorageBackend(), +}; + +type StoredEntry = Record; + +interface PendingWrite { + resolve: () => void; + reject: (error: unknown) => void; +} + +/** + * Calls a backend method straight away, turning a synchronous throw into a + * rejection: the promise executor runs synchronously and rejects with + * whatever it throws. `Promise.resolve().then(operation)` would also catch + * the throw, but would start the write a microtask late. + */ +const callBackend = ( + operation: () => Promise, +): Promise => + // eslint-disable-next-line sonarjs/prefer-promise-shorthand + new Promise((resolve) => { + resolve(operation()); + }); + +const parseEntry = (name: string, stored: string): StoredEntry => { + let parsed: unknown; + + try { + parsed = JSON.parse(stored); + } catch (error) { + throw new PersistentStateFormatError(name, error); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new PersistentStateFormatError(name); + } + + return parsed as StoredEntry; +}; + +/** + * Creates a persistent state: a named record of flat values (numbers, + * strings and booleans) with defaults, kept outside the game through a + * `StorageBackend`, `localStorage` by default. + * + * The stored entry is read once, here. A field that isn't stored takes its + * default. Only fields passed to `set` are stored, as a JSON object under + * `name`, so a default changed in a later version of the game reaches + * everyone who never set that field. Stored fields this version doesn't + * know are kept. Use one record per `name`, prefixed with the game's name: + * two records with one name overwrite each other's entry, and + * `localStorage` keys are shared by every page of an origin. + * + * Writes go out one at a time, in order. A change made while a write is in + * flight goes out with the next write, so a backend that settles out of + * order can't store older values over newer ones. With the `localStorage` + * backend, a change is stored before `set` returns. + * @param name - The key the record is stored under. + * @param defaults - Every field of the record, with the value it has when + * it isn't stored. + * @param options - The validators and the storage backend. + * @returns A promise that resolves with the record once its entry is + * read. Rejects with the backend's error if the entry can't be read, with + * a `PersistentStateFormatError` if it isn't a JSON object, and with a + * `PersistentStateValueError` if a default or a stored field isn't a + * finite number where it should be, fails its validator, or (for a stored + * field) doesn't have its default's type. + */ +export async function createPersistentState>( + name: string, + defaults: T, + options: Partial> = {}, +): Promise> { + const { validators, storage }: PersistentStateOptions = { + ...defaultPersistentStateOptions, + ...options, + }; + + const isValid = (field: string, value: unknown): boolean => { + if (!Object.hasOwn(defaults, field)) { + return false; + } + + const key = field as keyof T; + + if (typeof value !== typeof defaults[key]) { + return false; + } + + if (typeof value === 'number' && !Number.isFinite(value)) { + return false; + } + + const validator = validators[key]; + + return validator === undefined || validator(value); + }; + + for (const [field, value] of Object.entries(defaults)) { + if (!isValid(field, value)) { + throw new PersistentStateValueError(name, field, value); + } + } + + const stored = await callBackend(() => storage.get(name)); + let entry: StoredEntry = stored === null ? {} : parseEntry(name, stored); + const loaded: Partial = {}; + + for (const field of Object.keys(defaults)) { + if (!Object.hasOwn(entry, field)) { + continue; + } + + const value = entry[field]; + + if (!isValid(field, value)) { + throw new PersistentStateValueError(name, field, value); + } + + loaded[field as keyof T] = value as T[keyof T]; + } + + let values: Readonly = Object.freeze({ ...defaults, ...loaded }); + const onChange = new ParameterizedForgeEvent>( + `persistentState.${name}.onChange`, + ); + + let writing = false; + let nextWrite: PendingWrite[] | null = null; + + const startWrite = (): Promise => { + const isEmpty = Object.keys(entry).length === 0; + const serialized = JSON.stringify(entry); + + writing = true; + + const write = callBackend(() => + isEmpty ? storage.remove(name) : storage.set(name, serialized), + ); + + const onSettled = (): void => { + writing = false; + + if (nextWrite === null) { + return; + } + + const waiting = nextWrite; + + nextWrite = null; + startWrite().then( + () => { + for (const pending of waiting) { + pending.resolve(); + } + }, + (error: unknown) => { + for (const pending of waiting) { + pending.reject(error); + } + }, + ); + }; + + write.then(onSettled, onSettled); + + return write; + }; + + const queueWrite = (): Promise => { + if (!writing) { + return startWrite(); + } + + const waiting = nextWrite ?? []; + + nextWrite = waiting; + + return new Promise((resolve, reject) => { + waiting.push({ resolve, reject }); + }); + }; + + const applyValues = (newValues: T): void => { + values = Object.freeze(newValues); + onChange.raise(values); + }; + + return { + name, + get values(): Readonly { + return values; + }, + onChange, + set: (changes: Partial): Promise => { + const entries = Object.entries(changes); + + for (const [field, value] of entries) { + if (!isValid(field, value)) { + throw new PersistentStateValueError(name, field, value); + } + } + + entry = { ...entry, ...changes }; + applyValues({ ...values, ...changes }); + + return queueWrite(); + }, + reset: (): Promise => { + entry = {}; + applyValues({ ...defaults }); + + return queueWrite(); + }, + }; +} diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 000000000..fe0454d34 --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,6 @@ +export * from './create-local-storage-backend.js'; +export * from './create-memory-storage-backend.js'; +export * from './create-persistent-state.js'; +export * from './persistent-state-errors.js'; +export * from './storage-backend.js'; +export * from './storage-errors.js'; diff --git a/src/storage/persistent-state-errors.ts b/src/storage/persistent-state-errors.ts new file mode 100644 index 000000000..7e1001fd6 --- /dev/null +++ b/src/storage/persistent-state-errors.ts @@ -0,0 +1,66 @@ +/** + * The entry stored for a persistent state isn't a JSON object: something + * else wrote it, or it's damaged. The parse error, if any, is its `cause`. + */ +export class PersistentStateFormatError extends Error { + /** The name of the persistent state the entry is stored under. */ + public readonly stateName: string; + + /** + * Creates a persistent state format error. + * @param stateName - The name of the persistent state the entry is stored under. + * @param cause - The error that caused it, if any. + */ + constructor(stateName: string, cause?: unknown) { + super( + `Unable to load persistent state "${stateName}": its stored entry isn't a JSON object.`, + { cause }, + ); + this.name = 'PersistentStateFormatError'; + this.stateName = stateName; + } +} + +/** + * A value of a persistent state doesn't have its default's type, isn't a + * finite number, or fails its validator. Thrown for a stored value when + * the state loads, and for a value passed to `set`. + */ +export class PersistentStateValueError extends Error { + /** The name of the persistent state the value is for. */ + public readonly stateName: string; + + /** The field the value is for. */ + public readonly field: string; + + /** The value, as stored or as passed to `set`. */ + public readonly value: unknown; + + /** + * Creates a persistent state value error. + * @param stateName - The name of the persistent state the value is for. + * @param field - The field the value is for. + * @param value - The value, as stored or as passed to `set`. + */ + constructor(stateName: string, field: string, value: unknown) { + super( + `Invalid value ${describeValue(value)} for field "${field}" of persistent state "${stateName}".`, + ); + this.name = 'PersistentStateValueError'; + this.stateName = stateName; + this.field = field; + this.value = value; + } +} + +const describeValue = (value: unknown): string => { + if (typeof value === 'string') { + return JSON.stringify(value); + } + + if (typeof value === 'object' && value !== null) { + return Array.isArray(value) ? 'array' : 'object'; + } + + return String(value); +}; diff --git a/src/storage/storage-backend.ts b/src/storage/storage-backend.ts new file mode 100644 index 000000000..bf11bcb38 --- /dev/null +++ b/src/storage/storage-backend.ts @@ -0,0 +1,39 @@ +/** + * Where persisted strings are kept: `localStorage`, IndexedDB, a remote + * endpoint or a desktop wrapper's file system. The interface is + * asynchronous so every one of them can implement it. + * + * Every backend keeps this contract: + * - Every promise it returns settles. A backend that talks to a network + * applies its own timeouts. + * - `remove` resolves once nothing is stored under the key, whether or not + * anything was. + * - Creating a backend touches nothing; only its methods touch storage, so + * a backend can be created where there's no `window`. + */ +export interface StorageBackend { + /** + * Reads the string stored under a key. + * @param key - The key to read. + * @returns The stored string, or `null` if nothing is stored under `key`. + * Rejects with a `StorageError` if storage can't be read. + */ + get(key: string): Promise; + + /** + * Stores a string under a key, replacing what was stored there. + * @param key - The key to store under. + * @param value - The string to store. + * @returns A promise that resolves once `value` is stored. Rejects with a + * `StorageError` if it can't be stored. + */ + set(key: string, value: string): Promise; + + /** + * Removes whatever is stored under a key. + * @param key - The key to remove. + * @returns A promise that resolves once nothing is stored under `key`. + * Rejects with a `StorageError` if it can't be removed. + */ + remove(key: string): Promise; +} diff --git a/src/storage/storage-errors.ts b/src/storage/storage-errors.ts new file mode 100644 index 000000000..8ca70f975 --- /dev/null +++ b/src/storage/storage-errors.ts @@ -0,0 +1,62 @@ +/** + * What every `StorageBackend` rejects with when storage fails. The error + * that caused it, if any, is its `cause`. + */ +export class StorageError extends Error { + /** + * Creates a storage error. + * @param message - What failed. + * @param cause - The error that caused it, if any. + */ + constructor(message: string, cause?: unknown) { + super(message, { cause }); + this.name = 'StorageError'; + } +} + +/** + * There's no storage to use: no `localStorage` in this environment, or an + * endpoint that can't be reached. + */ +export class StorageUnavailableError extends StorageError { + /** + * Creates a storage unavailable error. + * @param message - What failed. + * @param cause - The error that caused it, if any. + */ + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = 'StorageUnavailableError'; + } +} + +/** + * Storage exists but is blocked: by the browser's settings, a frame that's + * denied storage, or private browsing. + */ +export class StorageBlockedError extends StorageError { + /** + * Creates a storage blocked error. + * @param message - What failed. + * @param cause - The error that caused it, if any. + */ + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = 'StorageBlockedError'; + } +} + +/** + * Storage is full. + */ +export class StorageFullError extends StorageError { + /** + * Creates a storage full error. + * @param message - What failed. + * @param cause - The error that caused it, if any. + */ + constructor(message: string, cause?: unknown) { + super(message, cause); + this.name = 'StorageFullError'; + } +} diff --git a/src/storage/storage-without-window.test.ts b/src/storage/storage-without-window.test.ts new file mode 100644 index 000000000..9ff24fca9 --- /dev/null +++ b/src/storage/storage-without-window.test.ts @@ -0,0 +1,12 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +describe('the storage module where there is no window', () => { + it('imports and creates backends without throwing', async () => { + expect(typeof globalThis.window).toBe('undefined'); + + const storage = await import('./index'); + + expect(() => storage.createLocalStorageBackend()).not.toThrow(); + }); +});