@inglorious/store 11.0.1 → 11.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ Game engines solved state complexity years ago — Inglorious Store brings those
30
30
  - ✅ Entity-based state (manage multiple instances effortlessly)
31
31
  - ✅ No action creators, thunks, or slices
32
32
  - ✅ Predictable, testable, purely functional code
33
- - ✅ Built-in lifecycle events (`add`, `remove`)
33
+ - ✅ Built-in lifecycle events (`add`, `remove`, `patch`, `replace`)
34
34
  - ✅ 10x faster immutability than Redux Toolkit (Mutative vs Immer)
35
35
 
36
36
  ---
@@ -364,6 +364,56 @@ Inglorious Store has a few built-in events that you can use:
364
364
 
365
365
  - `add`: adds a new entity to the state. Triggers a `create` lifecycle event.
366
366
  - `remove`: removes an entity from the state. Triggers a `destroy` lifecycle event.
367
+ - `patch`: changes some of a standing entity and leaves the rest alone.
368
+ - `replace`: installs a new configuration on a standing entity, dropping what it does
369
+ not mention.
370
+
371
+ `patch` and `replace` cover the case `add` and `remove` do not: changing something that is
372
+ already in the world without taking it out of it. They are the same pair the web settled
373
+ long ago — `POST`, `DELETE`, `PUT`, `PATCH` — for the same reason:
374
+
375
+ ```javascript
376
+ // A patch touches only what it names. Anything else the entity already had survives,
377
+ // which is what makes it safe to nudge part of something mid-flight.
378
+ store.notify("patch", { id: "counter1", value: 10 })
379
+
380
+ // A replace says the whole of it is different, so anything it does not name is gone.
381
+ store.notify("replace", { id: "counter1", type: "Counter", value: 10 })
382
+ ```
383
+
384
+ A field set to `undefined` is set to _nothing_ in either, rather than being left alone:
385
+
386
+ ```javascript
387
+ // The key stops being mapped. This is not the same as saying nothing about it, which
388
+ // would leave whatever it was mapped to last time standing.
389
+ store.notify("patch", {
390
+ id: "keyboard",
391
+ mapping: { Escape: "pause", ArrowUp: undefined },
392
+ })
393
+ ```
394
+
395
+ Neither runs `create` or `destroy`: the entity never stopped standing, and something that
396
+ was never removed cannot have been destroyed. Naming a different `type` in either moves
397
+ the entity onto that type's handlers.
398
+
399
+ The practical use is a world whose rules change with its state — a keyboard whose keys
400
+ mean one thing on a menu and another in play:
401
+
402
+ ```javascript
403
+ const types = {
404
+ Keyboard: { keyDown: (entity, key) => report(key) },
405
+ Game: [
406
+ scenes({
407
+ menu: () => [{ id: "keyboard", type: "Keyboard", mapping: MENU_KEYS }],
408
+ play: () => [{ id: "keyboard", type: "Keyboard", mapping: PLAY_KEYS }],
409
+ }),
410
+ fsm({/* … */}),
411
+ ],
412
+ }
413
+ ```
414
+
415
+ The keyboard is one keyboard, not two: the mapping is patched when the state moves, and
416
+ `isDeepEqual` from `@inglorious/utils` is what tells the two apart.
367
417
 
368
418
  The lifecycle events can be used to define event handlers similar to constructor and destructor methods in OOP:
369
419
 
@@ -634,6 +684,19 @@ The wrapper snapshots the keys in the patch, applies them in `Start`, restores t
634
684
 
635
685
  ---
636
686
 
687
+ ### Reading a store from outside a handler
688
+
689
+ `api.getEntity` is the answer during a handler. Outside one — a test driving a world, or
690
+ anything else holding the store rather than an event — the store says the same thing
691
+ itself:
692
+
693
+ ```javascript
694
+ store.getEntity("user") // one entity, or undefined
695
+ store.getState() // the lot
696
+ ```
697
+
698
+ There is one lookup rather than two spellings of it: `api.getEntity` asks the store.
699
+
637
700
  ### Event scoping
638
701
 
639
702
  By default, lifecycle events are **scoped to the triggering entity**:
@@ -1072,6 +1135,7 @@ const store = createStore({
1072
1135
  systems, // Array (optional): global state handlers
1073
1136
  autoCreateEntities, // Boolean (optional): false (default) or true
1074
1137
  updateMode, // String (optional): 'auto' (default) or 'manual'
1138
+ updateStrategy, // String (optional): 'structural-sharing' (default) or 'full-clone'
1075
1139
  })
1076
1140
  ```
1077
1141
 
@@ -1088,6 +1152,79 @@ const store = createStore({
1088
1152
  - **`updateMode`** (optional) - Controls when React components re-render:
1089
1153
  - `'auto'` (default) - Automatic updates after each event
1090
1154
  - `'manual'` - Manual control via `api.update()`
1155
+ - **`updateStrategy`** (optional) - Controls how the state is copied before queued events are applied:
1156
+ - `'structural-sharing'` (default) - Only the entities that actually changed are copied
1157
+ - `'full-clone'` - The whole state is deep-cloned and updated without a proxy
1158
+
1159
+ #### Update Strategy
1160
+
1161
+ `updateStrategy` decides how much copying work each update does. It exists because the right
1162
+ answer depends on how many entities change per update.
1163
+
1164
+ **`'structural-sharing'`** (default) applies events to a draft proxy (via
1165
+ [Mutative](https://github.com/pmndrs/mutative)), so only the entities that actually changed
1166
+ get copied. Untouched entities keep their previous object reference, which makes
1167
+ reference-equality change detection nearly free. This is what you want for UI stores, where a
1168
+ handful of entities change per interaction.
1169
+
1170
+ **`'full-clone'`** deep-clones the entire state with `structuredClone` and applies events directly to
1171
+ the copy, with no proxy involved. Every update costs time proportional to the total state size,
1172
+ but there's no per-entity proxy overhead, so it scales much better when thousands of entities
1173
+ change every frame — the typical case for a game simulation.
1174
+
1175
+ ```javascript
1176
+ // A UI store: few entities change, reference equality matters
1177
+ const store = createStore({
1178
+ types,
1179
+ entities,
1180
+ updateStrategy: "structural-sharing",
1181
+ })
1182
+
1183
+ // A game simulation: thousands of entities change every frame
1184
+ const store = createStore({ types, entities, updateStrategy: "full-clone" })
1185
+ ```
1186
+
1187
+ #### State swapping
1188
+
1189
+ Both strategies keep the current state intact while the queued events are applied to a separate
1190
+ draft, and swap the two only once every event has been processed. Handlers and systems always
1191
+ receive that draft as their first argument and mutate it directly, and subscribers are notified
1192
+ only after the swap.
1193
+
1194
+ A consequence worth knowing: during an update, `api.getEntity()` and `api.getEntities()` read the
1195
+ **previous** state, not the in-flight changes. This holds under both strategies, so code that
1196
+ needs to read another entity's up-to-date value must go through the event payload, or read it in
1197
+ a system that runs after the entity handlers.
1198
+
1199
+ ```javascript
1200
+ const types = {
1201
+ Counter: {
1202
+ // Reads the previous value of `other`, not the value set earlier in this same update.
1203
+ increment(entity, payload, api) {
1204
+ entity.previous = api.getEntity("other").count
1205
+ },
1206
+ },
1207
+ }
1208
+ ```
1209
+
1210
+ **Caveats for `'full-clone'`:**
1211
+
1212
+ - Every value in the state must be [structured-cloneable](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm).
1213
+ Functions, DOM nodes, and class instances with non-cloneable internals throw a
1214
+ `DataCloneError`. Keep such values in module scope or in a closure inside a behavior factory
1215
+ (as the `images()` and `audio()` engine behaviors do) rather than in the state.
1216
+ - The state is not frozen, even in `devMode`.
1217
+ - Writing to an entity obtained from `api.getEntity()` during an update mutates the _previous_
1218
+ state and is discarded, because the draft is a deep copy. Under `'structural-sharing'` the
1219
+ draft may still share that reference, so the write happens to survive. Always mutate the
1220
+ entity passed to your handler rather than one fetched through the api.
1221
+
1222
+ An unsupported strategy throws a `TypeError` at store creation:
1223
+
1224
+ ```javascript
1225
+ createStore({ types, entities, updateStrategy: "immutable" })
1226
+ // TypeError: Unsupported update strategy: immutable. Expected one of: structural-sharing, full-clone.
1227
+ ```
1091
1228
 
1092
1229
  #### Auto-Create Entities
1093
1230
 
@@ -1182,6 +1319,54 @@ Each handler receives three arguments:
1182
1319
 
1183
1320
  - **`create(entity)`** - triggered when entity added via `add` event, visible only to that entity
1184
1321
  - **`destroy(entity)`** - triggered when entity removed via `remove` event, visible only to that entity
1322
+ - **`pause`** - halts the world, see below
1323
+ - **`resume`** - unhalts it
1324
+ - **`add` / `remove`** - put a thing into the world or take it out, triggering `create` / `destroy`
1325
+ - **`patch` / `replace`** - change a thing that is already standing, the first touching only
1326
+ what it names and the second installing all of it
1327
+ - **`update`** - the frame tick, disabled while the world is paused
1328
+
1329
+ #### Pausing the world
1330
+
1331
+ `notify("pause")` stops the store handing out `update` events, and `notify("resume")` starts it again:
1332
+
1333
+ ```javascript
1334
+ store.notify("pause")
1335
+ store.notify("update")
1336
+ store.update() // nothing updated
1337
+
1338
+ store.notify("resume")
1339
+ store.notify("update")
1340
+ store.update() // updated as normal
1341
+ ```
1342
+
1343
+ This is for anything that moves by integrating a delta time. Those entities do not need to know about pausing at all: their `update` is simply never called, so they cannot drift.
1344
+
1345
+ Everything else keeps flowing. Input handlers, custom events and rendering are untouched, which is what lets a pause screen take itself back off:
1346
+
1347
+ ```javascript
1348
+ // Still works while the world is halted
1349
+ pauseMenu.pause(entity, _, api) {
1350
+ api.notify("resume")
1351
+ }
1352
+ ```
1353
+
1354
+ An entity opts out by setting `updatesWhilePaused`, which is for overlays and menus rather than for gameplay:
1355
+
1356
+ ```javascript
1357
+ const Overlay = [{ update(entity, dt) { entity.ticks++ } }]
1358
+
1359
+ // in entities
1360
+ overlay: { type: "Overlay", updatesWhilePaused: true }
1361
+ ```
1362
+
1363
+ It is a property of the entity rather than of the type so that a type stays a plain index signature of handlers, which is what keeps it freely callable and indexable from TypeScript.
1364
+
1365
+ The default is to **stop**. Anything that keeps updating has to say so, so forgetting a flag pauses the thing you meant to pause rather than freezing the menu.
1366
+
1367
+ `reset()` clears the halt, so replacing the entities does not leave the world frozen.
1368
+
1369
+ Note that `pause` and `resume` keep dispatching like any other event, unlike `add` and `remove`. The store acts on them _and_ every type that handles them still gets called, so one notification can both halt the world and let the game record why.
1185
1370
 
1186
1371
  ### Notify vs Dispatch
1187
1372
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "11.0.1",
3
+ "version": "11.0.3",
4
4
  "description": "A state manager for real-time, collaborative apps, inspired by game development patterns and compatible with Redux.",
5
5
  "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -55,18 +55,18 @@
55
55
  },
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@inglorious/utils": "3.8.0",
58
+ "@inglorious/utils": "5.0.0",
59
59
  "mutative": "^1.3.0"
60
60
  },
61
61
  "peerDependencies": {
62
- "@inglorious/utils": "3.8.0"
62
+ "@inglorious/utils": "5.0.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@inglorious/eslint-config": "1.1.2",
66
- "@reduxjs/toolkit": "^2.11.2",
67
- "prettier": "^3.6.2",
68
- "vite": "^7.1.3",
69
- "vitest": "^1.6.1"
66
+ "@reduxjs/toolkit": "^2.12.0",
67
+ "prettier": "^3.9.6",
68
+ "vite": "^8.2.2",
69
+ "vitest": "^5.0.0"
70
70
  },
71
71
  "engines": {
72
72
  "node": ">= 22"
package/src/api.js CHANGED
@@ -37,10 +37,14 @@ export function createApi(store, extras) {
37
37
  },
38
38
  /**
39
39
  * Retrieves a single entity by ID.
40
+ *
41
+ * The store answers this for itself, so there is one lookup rather than two
42
+ * spellings of the same one.
43
+ *
40
44
  * @param {string} id
41
45
  * @returns {Object | undefined}
42
46
  */
43
- getEntity: (id) => store.getState()[id],
47
+ getEntity: store.getEntity,
44
48
  /**
45
49
  * Runs a selector against the current state.
46
50
  *
@@ -1,4 +1,4 @@
1
- import { deserialize } from "@inglorious/utils/data-structures/object.js"
1
+ import { deserialize } from "@inglorious/utils/object.js"
2
2
 
3
3
  const LAST_STATE = 1
4
4
 
@@ -55,21 +55,21 @@ function connectDevTools(store, config = {}) {
55
55
  // Hot-swap store without resetting DevTools history
56
56
  existing.restoreSetState?.()
57
57
  const baseSetState = store.setState
58
- let restoreSetState = null
58
+ const restoreSetState =
59
+ updateMode === "auto"
60
+ ? () => {
61
+ if (store.setState !== baseSetState) {
62
+ store.setState = baseSetState
63
+ }
64
+ }
65
+ : () => {
66
+ // no-op when not wrapping setState
67
+ }
59
68
  if (updateMode === "auto") {
60
- restoreSetState = () => {
61
- if (store.setState !== baseSetState) {
62
- store.setState = baseSetState
63
- }
64
- }
65
69
  store.setState = (newState) => {
66
70
  baseSetState(newState)
67
71
  sendAction({ type: "stateInit", payload: newState }, store.getState())
68
72
  }
69
- } else {
70
- restoreSetState = () => {
71
- // no-op when not wrapping setState
72
- }
73
73
  }
74
74
  existing.store = store
75
75
  existing.restoreSetState = restoreSetState
@@ -90,22 +90,22 @@ function connectDevTools(store, config = {}) {
90
90
 
91
91
  const name = config.name ?? document.title
92
92
  const baseSetState = store.setState
93
- let restoreSetState = null
93
+ const restoreSetState =
94
+ updateMode === "auto"
95
+ ? () => {
96
+ if (store.setState !== baseSetState) {
97
+ store.setState = baseSetState
98
+ }
99
+ }
100
+ : () => {
101
+ // noop; we didn't wrap setState
102
+ }
94
103
  // Only add setState side-effects in auto update mode; manual update mode logs explicitly from engine.
95
104
  if (updateMode === "auto") {
96
- restoreSetState = () => {
97
- if (store.setState !== baseSetState) {
98
- store.setState = baseSetState
99
- }
100
- }
101
105
  store.setState = (newState) => {
102
106
  baseSetState(newState)
103
107
  sendAction({ type: "stateInit", payload: newState }, store.getState())
104
108
  }
105
- } else {
106
- restoreSetState = () => {
107
- // noop; we didn't wrap setState
108
- }
109
109
  }
110
110
 
111
111
  const devToolsInstance = window.__REDUX_DEVTOOLS_EXTENSION__.connect({
@@ -1,19 +1,25 @@
1
- import {
2
- deserialize,
3
- serialize,
4
- } from "@inglorious/utils/data-structures/object.js"
5
- import { extend } from "@inglorious/utils/data-structures/objects.js"
6
-
7
- // A constant for the server's WebSocket URL.
8
- const DEFAULT_SERVER_URL = `ws://${window.location.hostname}:3000`
1
+ import { deserialize, extend, serialize } from "@inglorious/utils/object.js"
2
+
9
3
  const DEFAULT_RECONNECTION_DELAY = 1000
10
4
 
5
+ /**
6
+ * Where the server is, when the caller has not said.
7
+ *
8
+ * Read when it is asked for rather than when this module is imported, so that importing it
9
+ * does not require a browser -- which is what lets the store be imported anywhere.
10
+ *
11
+ * @returns {string} The server's WebSocket URL.
12
+ */
13
+ function defaultServerUrl() {
14
+ return `ws://${window.location.hostname}:3000`
15
+ }
16
+
11
17
  /**
12
18
  * Creates and returns the multiplayer middleware.
13
19
  * @returns {Function} The middleware function.
14
20
  */
15
21
  export function multiplayerMiddleware(config = {}) {
16
- const serverUrl = config.serverUrl ?? DEFAULT_SERVER_URL
22
+ const serverUrl = config.serverUrl ?? defaultServerUrl()
17
23
  const reconnectionDelay =
18
24
  config.reconnectionDelay ?? DEFAULT_RECONNECTION_DELAY
19
25
 
package/src/entities.js CHANGED
@@ -1,4 +1,4 @@
1
- import { map } from "@inglorious/utils/data-structures/object.js"
1
+ import { map } from "@inglorious/utils/object.js"
2
2
 
3
3
  /**
4
4
  * @typedef {Object.<string, any>} Entity - An object representing an entity.
@@ -1,4 +1,4 @@
1
- import { compose } from "@inglorious/utils/functions/functions.js"
1
+ import { compose } from "@inglorious/utils/functions"
2
2
 
3
3
  /**
4
4
  * Applies a list of middleware functions to a store's dispatch method.
@@ -5,7 +5,7 @@
5
5
  * Inglorious types, enabling gradual migration from RTK to Inglorious Store.
6
6
  */
7
7
 
8
- import { extend } from "@inglorious/utils/data-structures/objects.js"
8
+ import { extend } from "@inglorious/utils/object.js"
9
9
 
10
10
  import { handleAsync } from "../async"
11
11
 
package/src/store.js CHANGED
@@ -7,16 +7,46 @@ import { EventMap, parseEvent } from "./event-map.js"
7
7
  import { applyMiddlewares } from "./middlewares.js"
8
8
  import { augmentType, augmentTypes } from "./types.js"
9
9
 
10
+ /** Copies only the entities that change, using a draft proxy. */
11
+ const STRUCTURAL_SHARING = "structural-sharing"
12
+
13
+ /** Deep-clones the whole state and applies events to the copy, without a proxy. */
14
+ const FULL_CLONE = "full-clone"
15
+
16
+ const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, FULL_CLONE]
17
+
10
18
  /**
11
19
  * Creates a store to manage state and events.
12
- * @param {Object} config - Configuration options for the store.
13
- * @param {Object} [config.types] - The initial types configuration.
14
- * @param {Object} [config.entities] - The initial entities configuration.
15
- * @param {Array} [config.systems] - The initial systems configuration.
16
- * @param {Array} [config.middlewares] - The initial middlewares configuration.
17
- * @param {boolean} [config.autoCreateEntities] - Creates entities if not defined in `config.entities`.
18
- * @param {"auto" | "manual"} [config.updateMode] - The update mode (defaults to "auto").
19
- * @returns {Object} The store with methods to interact with state and events.
20
+ *
21
+ * Events are queued and drained by {@link createStore|update}. The `updateStrategy` option
22
+ * controls how the state is copied before the queued events are applied:
23
+ *
24
+ * - `"structural-sharing"` (default) - Applies events to a draft proxy, so only the entities
25
+ * that actually changed are copied. Unchanged entities keep their previous reference, which
26
+ * makes change detection cheap for consumers that rely on reference equality. This is the
27
+ * right choice for UI stores, where a small number of entities change at a time.
28
+ * - `"full-clone"` - Deep-clones the whole state with `structuredClone` and applies events directly
29
+ * to the copy, without a proxy. The cost is proportional to the total state size on every
30
+ * update, but there is no per-entity proxy overhead, so it scales better when thousands of
31
+ * entities change every frame, as in a game simulation.
32
+ *
33
+ * Under both strategies the current state is kept intact while the queued events are applied to a
34
+ * separate draft, and the two are swapped only once every event has been processed. Handlers and
35
+ * systems therefore always receive the draft as their first argument and mutate it directly, and
36
+ * subscribers are notified only after the swap. This also means that, during an update,
37
+ * `api.getEntity()` and `api.getEntities()` read the *previous* state rather than the in-flight
38
+ * changes, under either strategy.
39
+ *
40
+ * @param {StoreConfig} config - Configuration options for the store.
41
+ * @param {Types} [config.types] - The initial types configuration.
42
+ * @param {Entities} [config.entities] - The initial entities configuration.
43
+ * @param {Object[]} [config.systems] - The initial systems configuration.
44
+ * @param {Function[]} [config.middlewares] - The initial middlewares configuration.
45
+ * @param {boolean} [config.autoCreateEntities=false] - Creates entities if not defined in `config.entities`.
46
+ * @param {"auto" | "manual"} [config.updateMode="auto"] - Whether each event triggers an update, or updates are batched until `update()` is called.
47
+ * @param {UpdateStrategy} [config.updateStrategy="structural-sharing"] - How the state is copied before events are applied.
48
+ * @returns {Store} The store with methods to interact with state and events.
49
+ * @throws {TypeError} If `updateStrategy` is not a supported strategy.
20
50
  */
21
51
  export function createStore({
22
52
  types: originalTypes = {},
@@ -25,12 +55,15 @@ export function createStore({
25
55
  middlewares = [],
26
56
  autoCreateEntities = false,
27
57
  updateMode = "auto",
58
+ updateStrategy = STRUCTURAL_SHARING,
28
59
  } = {}) {
60
+ assertUpdateStrategy(updateStrategy)
61
+
29
62
  const listeners = new Set()
30
63
 
31
64
  const types = augmentTypes(originalTypes)
32
65
 
33
- let state, eventMap, incomingEvents, isProcessing
66
+ let state, eventMap, incomingEvents, isProcessing, isHalted
34
67
  reset()
35
68
 
36
69
  const baseStore = {
@@ -42,6 +75,7 @@ export function createStore({
42
75
  getType,
43
76
  setType,
44
77
  getState,
78
+ getEntity,
45
79
  setState,
46
80
  reset,
47
81
  }
@@ -73,39 +107,128 @@ export function createStore({
73
107
 
74
108
  /**
75
109
  * Updates the state based on elapsed time and processes events.
110
+ *
111
+ * The current state is left untouched while the queued events are applied to a separate
112
+ * draft, according to the configured `updateStrategy`. The draft only replaces the current
113
+ * state once every queued event has been processed, and subscribers are notified afterwards.
114
+ *
115
+ * @returns {Event[]} The events processed during this update.
76
116
  */
77
117
  function update() {
78
118
  if (isProcessing) {
79
119
  return []
80
120
  }
81
121
 
122
+ // Nothing queued means nothing to apply and nothing to publish: skipping the draft
123
+ // entirely, rather than copying the world just to arrive back where it started.
124
+ // This is what keeps a loop that pumps every animation frame cheap on the frames
125
+ // where neither the game nor the player had anything to say.
126
+ if (!incomingEvents.length) {
127
+ return []
128
+ }
129
+
82
130
  isProcessing = true
83
131
  const processedEvents = []
132
+ let nextState
84
133
 
85
- state = create(state, patcher, {
86
- enableAutoFreeze: state.game?.devMode,
87
- })
134
+ if (updateStrategy === STRUCTURAL_SHARING) {
135
+ nextState = create(state, patch, {
136
+ enableAutoFreeze: state.game?.devMode,
137
+ })
138
+ } else {
139
+ const draft = structuredClone(state)
140
+
141
+ patch(draft)
142
+ nextState = draft
143
+ }
88
144
 
145
+ state = nextState
89
146
  isProcessing = false
90
147
 
91
148
  listeners.forEach((onUpdate) => onUpdate())
92
149
 
93
150
  return processedEvents
94
151
 
95
- function patcher(draft) {
152
+ /**
153
+ * Applies every queued event to the given draft.
154
+ *
155
+ * The draft is either the proxy returned by `create` (structural sharing) or the
156
+ * deep copy produced by `structuredClone` (full clone). The current state is not
157
+ * modified, and only becomes the new state once this function returns.
158
+ *
159
+ * A change made by a handler is not visible to the next handler in the same pass:
160
+ * `getState` still returns the state as it was before the pass began. This is on
161
+ * purpose -- every event in the pass sees one consistent world, rather than some
162
+ * changes having landed and others not -- and it means a handler that asks what is
163
+ * left is asking about the past. Anything reacting to a change should be told the
164
+ * change rather than go looking for it.
165
+ *
166
+ * @param {Entities} draft - The draft to apply the queued events to.
167
+ * @returns {void}
168
+ */
169
+ function patch(draft) {
96
170
  while (incomingEvents.length) {
97
171
  const event = incomingEvents.shift()
98
172
  processedEvents.push(event)
99
173
 
174
+ // Pausing and resuming are the store's own, and they keep dispatching like any
175
+ // other event, so the game behaviour still sees them and anything else that
176
+ // wants to react gets the same notification.
177
+ if (event.type === "pause") {
178
+ isHalted = true
179
+ }
180
+
181
+ if (event.type === "resume") {
182
+ isHalted = false
183
+ }
184
+
100
185
  // Handle special system events
186
+ // `add` and `remove` change the world and are then dispatched like any
187
+ // other event, so that whatever is watching can hear about it. They are
188
+ // broadcast: the entity named by the payload is not the only one that wants
189
+ // to know, and something counting down what it put up needs to hear about
190
+ // each of them going.
191
+ //
192
+ // They are not the counterpart to `create` and `destroy`, which reach only
193
+ // the entity they are about so that those read as a constructor and a
194
+ // destructor without every handler beginning by checking whether it is the
195
+ // one being talked about.
196
+ //
197
+ // The two carry different payloads, and that is on purpose: a payload
198
+ // carries the least that can be had. A removal needs only the id, because
199
+ // everything else about the entity can still be looked up by it. An
200
+ // addition carries the entity itself, because there is nothing yet to look
201
+ // it up by -- the same asymmetry as removing a user by id but updating one
202
+ // by the patched user.
101
203
  if (event.type === "add") {
102
204
  addEntity(draft, event.payload)
103
- continue
104
205
  }
105
206
 
106
207
  if (event.type === "remove") {
107
208
  removeEntity(draft, event.payload)
108
- continue
209
+ }
210
+
211
+ // A thing that stands through a change of state can still be told a different
212
+ // configuration, in either of the two ways the world offers.
213
+ //
214
+ // `patch` changes some of it and leaves the rest alone, which is how a screen
215
+ // takes over one field of something it shares with the screen before it. A field
216
+ // named as `undefined` is set to nothing rather than being left alone, which is
217
+ // how a key is taken off a mapping.
218
+ //
219
+ // `replace` installs what it is given and drops everything it does not mention,
220
+ // which is how the whole of something is said to be different.
221
+ //
222
+ // Neither takes the thing out of the world, so neither runs `create` or `destroy`:
223
+ // it never stopped standing, and a thing that was never removed cannot have been
224
+ // destroyed. This is the same pair the world has to hand for anything else --
225
+ // `add` and `remove` beside them, and post/delete/put/patch before that.
226
+ if (event.type === "patch") {
227
+ patchEntity(draft, event.payload)
228
+ }
229
+
230
+ if (event.type === "replace") {
231
+ replaceEntity(draft, event.payload)
109
232
  }
110
233
 
111
234
  // Parse the event to get handler name
@@ -114,9 +237,21 @@ export function createStore({
114
237
  // Get entities that should handle this event (filtered by EventMap)
115
238
  const entityIds = eventMap.getEntitiesForEvent(event.type)
116
239
 
240
+ // A halted world stops handing out updates, because anything that moves by
241
+ // integrating a delta time would otherwise carry on regardless. An entity that
242
+ // sets `updatesWhilePaused` is exempt, which is how an overlay, a button or the
243
+ // thing that takes the pause back off keeps working while the rest stands still.
244
+ //
245
+ // It is read off the entity rather than the type so that the type stays an index
246
+ // signature of handlers, which is what lets it be called and indexed freely.
247
+ const halted = isHalted && handlerName === "update"
248
+
117
249
  for (const id of entityIds) {
118
250
  const entity = draft[id]
119
251
  const type = types[entity.type]
252
+
253
+ if (halted && !entity.updatesWhilePaused) continue
254
+
120
255
  const handle = type[handlerName]
121
256
 
122
257
  handle?.(entity, event.payload, api)
@@ -151,8 +286,14 @@ export function createStore({
151
286
  * - 'form:submit' - only form entities
152
287
  * - 'form[loginForm]:submit' - only loginForm entity
153
288
  *
289
+ * Two of these are the store's own. 'pause' stops the store handing out 'update'
290
+ * events, and 'resume' starts it again, which is how anything that moves by
291
+ * integrating a delta time stops without being told about it. A type declares
292
+ * `updatesWhilePaused` to keep updating regardless. Both keep dispatching to types
293
+ *
154
294
  * @param {string} type - The event type to notify.
155
- * @param {any} payload - The event payload.
295
+ * @param {any} payload - The event payload. Carries the least that can be had
296
+ * about what happened.
156
297
  */
157
298
  function notify(type, payload) {
158
299
  // NOTE: it's important to invoke store.dispatch instead of dispatch, otherwise we cannot override it
@@ -193,7 +334,8 @@ export function createStore({
193
334
  /**
194
335
  * Sets an augmented type configuration given its name.
195
336
  * @param {string} typeName - The name of the type to set.
196
- * @param {Object} type - The type configuration.
337
+ * @param {Type} type - The type configuration.
338
+ * @returns {void}
197
339
  */
198
340
  function setType(typeName, type) {
199
341
  const oldType = types[typeName]
@@ -226,10 +368,25 @@ export function createStore({
226
368
  return state
227
369
  }
228
370
 
371
+ /**
372
+ * One entity by its id, or `undefined` if there is no such entity.
373
+ *
374
+ * This is what `api.getEntity` answers with during a handler, exposed here for the
375
+ * code that runs outside one: a test looking at a world it is driving, or anything else
376
+ * holding the store rather than an event's `api`.
377
+ *
378
+ * @param {string} id - The entity's id.
379
+ * @returns {Object|undefined} The entity, as the last finished pass left it.
380
+ */
381
+ function getEntity(id) {
382
+ return state?.[id]
383
+ }
384
+
229
385
  /**
230
386
  * Sets the entire state of the store.
231
387
  * This is useful for importing state or setting initial state from a server.
232
- * @param {Object} nextState - The new state to set.
388
+ * @param {Entities} nextState - The new state to set.
389
+ * @returns {void}
233
390
  */
234
391
  function setState(nextState) {
235
392
  const oldEntities = state ?? {}
@@ -289,11 +446,19 @@ export function createStore({
289
446
 
290
447
  /**
291
448
  * Resets the store to its initial state.
449
+ * @returns {void}
292
450
  */
293
451
  function reset() {
452
+ isHalted = false
294
453
  setState(originalEntities)
295
454
  }
296
455
 
456
+ /**
457
+ * Adds an entity to the state and registers its event handlers.
458
+ * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
459
+ * @param {Entity} payload - The entity to add, including its id.
460
+ * @returns {void}
461
+ */
297
462
  function addEntity(draft, payload) {
298
463
  const { id, ...entity } = payload
299
464
  draft[id] = augmentEntity(id, entity)
@@ -303,9 +468,109 @@ export function createStore({
303
468
  incomingEvents.unshift({ type: `#${id}:create` })
304
469
  }
305
470
 
306
- function removeEntity(draft, payload) {
307
- const id = payload
471
+ /**
472
+ * Moves a standing thing onto the handlers of whatever type it now names.
473
+ *
474
+ * A type is the only thing about a thing that cannot be merged: there is no such thing
475
+ * as being half a sort, so naming a different one means taking the thing out of the old
476
+ * one's list of entities before putting it in the new one.
477
+ *
478
+ * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
479
+ * @param {string} id - The thing being moved.
480
+ * @param {string} previousType - The type it was under.
481
+ * @param {string|undefined} type - The type it is under now, if it has named one.
482
+ * @returns {void}
483
+ */
484
+ function moveToType(draft, id, previousType, type) {
485
+ if (!type || type === previousType) return
486
+
487
+ eventMap.removeEntity(id, types[previousType], previousType)
488
+ eventMap.addEntity(id, types[type], type)
489
+ }
490
+
491
+ /**
492
+ * Merges a new configuration into a thing that is already standing.
493
+ *
494
+ * Only the fields the configuration names are touched, so anything the world has done
495
+ * to the thing since it was made survives unless the
496
+ * new configuration says otherwise. A field named as `undefined` is set to nothing,
497
+ * which is what taking a key off a mapping means.
498
+ *
499
+ * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
500
+ * @param {Entity} payload - The thing and the fields to merge into it.
501
+ * @returns {void}
502
+ */
503
+ function patchEntity(draft, payload) {
504
+ const { id, ...config } = payload
505
+ const existing = draft[id]
506
+
507
+ // Configuring something that is not there is not a thing that can be done. The world
508
+ // is built in one pass, so a scene naming a thing another scene is yet to put up is
509
+ // ordinary rather than a mistake, and is not worth failing over.
510
+ if (!existing) return
511
+
512
+ draft[id] = augmentEntity(id, { ...existing, ...config })
513
+
514
+ moveToType(draft, id, existing.type, config.type)
515
+ }
516
+
517
+ /**
518
+ * Installs a new configuration on a standing thing, dropping what it does not mention.
519
+ *
520
+ * The thing keeps its place in the world and its name; everything else is whatever it
521
+ * is told, and anything it is not told is gone. A field set to `undefined` is set to
522
+ * nothing here as much as it is in a patch -- the difference between the two is what
523
+ * happens to the fields neither mentions.
524
+ *
525
+ * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
526
+ * @param {Entity} payload - The thing and the fields it is to be.
527
+ * @returns {void}
528
+ */
529
+ function replaceEntity(draft, payload) {
530
+ const { id, ...config } = payload
531
+ const existing = draft[id]
532
+
533
+ if (!existing) return
308
534
 
535
+ draft[id] = augmentEntity(id, config)
536
+
537
+ moveToType(draft, id, existing.type, config.type)
538
+ }
539
+
540
+ /**
541
+ * Queues the destruction of an entity. The entity is removed by the `#id:destroy` event.
542
+ * @param {Entities} draft - The draft (structural sharing) or the copy (full clone) to mutate.
543
+ * @param {string} id - The id of the entity to remove.
544
+ * @returns {void}
545
+ */
546
+ function removeEntity(draft, id) {
309
547
  incomingEvents.unshift({ type: `#${id}:destroy` })
310
548
  }
311
549
  }
550
+
551
+ /**
552
+ * Validates the given update strategy.
553
+ * @param {UpdateStrategy} updateStrategy - The update strategy to validate.
554
+ * @returns {void}
555
+ * @throws {TypeError} If the update strategy is not supported.
556
+ */
557
+ function assertUpdateStrategy(updateStrategy) {
558
+ if (UPDATE_STRATEGIES.includes(updateStrategy)) {
559
+ return
560
+ }
561
+
562
+ throw new TypeError(
563
+ `Unsupported update strategy: ${updateStrategy}. Expected one of: ${UPDATE_STRATEGIES.join(", ")}.`,
564
+ )
565
+ }
566
+
567
+ /**
568
+ * @typedef {"structural-sharing" | "full-clone"} UpdateStrategy - How the state is copied before events are applied.
569
+ * @typedef {Object} StoreConfig - Configuration options accepted by {@link createStore}.
570
+ * @typedef {Object.<string, any>} Type - An augmented entity type.
571
+ * @typedef {Object.<string, Type>} Types - A collection of augmented types.
572
+ * @typedef {Object.<string, any>} Entity - An object representing an entity.
573
+ * @typedef {Object.<string, Entity>} Entities - A collection of entities indexed by id.
574
+ * @typedef {(listener: () => void) => () => void} Listener - Subscribes to state updates and returns an unsubscribe function.
575
+ * @typedef {Object} Store - The store returned by {@link createStore}.
576
+ */
package/src/types.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { ensureArray } from "@inglorious/utils/data-structures/array.js"
2
- import { map } from "@inglorious/utils/data-structures/object.js"
3
- import { extend } from "@inglorious/utils/data-structures/objects.js"
4
- import { pipe } from "@inglorious/utils/functions/functions.js"
2
+ import { pipe } from "@inglorious/utils/functions"
3
+ import { extend, map } from "@inglorious/utils/object.js"
5
4
 
6
5
  /**
7
6
  * Augments a single type by composing its behaviors and mixins.
package/types/store.d.ts CHANGED
@@ -17,7 +17,12 @@ export interface Event<T = any> {
17
17
  }
18
18
 
19
19
  /**
20
- * Entity type definition with event handlers
20
+ * Entity type definition with event handlers.
21
+ *
22
+ * `updatesWhilePaused` opts the type out of a pause: the store stops handing out
23
+ * `update` events while the world is halted, and a type that says this keeps
24
+ * receiving them. It is for overlays and menus; anything that moves should leave it
25
+ * off so that it stops.
21
26
  */
22
27
  export type EntityType<TEntity extends BaseEntity = BaseEntity> = {
23
28
  [K: string]: (entity: TEntity, payload: any, api: Api) => void
@@ -43,6 +48,28 @@ export type EntitiesState<TEntity extends BaseEntity = BaseEntity> = {
43
48
  export type TypesConfig<TEntity extends BaseEntity = BaseEntity> = {
44
49
  [typeName: string]: EntityType<TEntity>
45
50
  }
51
+ /**
52
+ * Whether each event triggers an update, or updates are batched until `update()` is called.
53
+ */
54
+ export type UpdateMode = "auto" | "manual"
55
+
56
+ /**
57
+ * How the state is copied before queued events are applied.
58
+ *
59
+ * - `"structural-sharing"` (default) applies events to a draft proxy, so only the entities that
60
+ * actually changed are copied. Unchanged entities keep their previous reference, which keeps
61
+ * reference-equality change detection cheap. Best for UI stores.
62
+ * - `"full-clone"` deep-clones the whole state with `structuredClone` and applies events to the
63
+ * copy without a proxy. Cost is proportional to the total state size per update, but there is
64
+ * no per-entity proxy overhead, so it scales better when thousands of entities change every
65
+ * frame, as in a game simulation.
66
+ *
67
+ * Under both strategies the current state is left intact while the queued events are applied to
68
+ * a separate draft, and the two are swapped only once every event has been processed. This means
69
+ * that during an update `api.getEntity()` and `api.getEntities()` read the previous state rather
70
+ * than the in-flight changes.
71
+ */
72
+ export type UpdateStrategy = "structural-sharing" | "full-clone"
46
73
 
47
74
  /**
48
75
  * Store configuration
@@ -56,7 +83,8 @@ export interface StoreConfig<
56
83
  systems?: System<TState>[]
57
84
  middlewares?: Middleware<TEntity, TState>[]
58
85
  autoCreateEntities?: boolean
59
- mode?: "eager" | "batched"
86
+ updateMode?: UpdateMode
87
+ updateStrategy?: UpdateStrategy
60
88
  }
61
89
 
62
90
  /**
@@ -84,6 +112,7 @@ export interface Store<
84
112
  getType: (typeName: string) => EntityType<TEntity>
85
113
  setType: (typeName: string, type: EntityType<TEntity>) => void
86
114
  getState: () => TState
115
+ getEntity: (id: string) => TState[string] | undefined
87
116
  setState: (nextState: TState) => void
88
117
  reset: () => void
89
118
  _api?: Api<TEntity, TState>