@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 +186 -1
- package/package.json +7 -7
- package/src/api.js +5 -1
- package/src/client/devtools.js +21 -21
- package/src/client/multiplayer-middleware.js +15 -9
- package/src/entities.js +1 -1
- package/src/middlewares.js +1 -1
- package/src/migration/rtk.js +1 -1
- package/src/store.js +285 -20
- package/src/types.js +2 -3
- package/types/store.d.ts +31 -2
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.
|
|
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": "
|
|
58
|
+
"@inglorious/utils": "5.0.0",
|
|
59
59
|
"mutative": "^1.3.0"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
|
-
"@inglorious/utils": "
|
|
62
|
+
"@inglorious/utils": "5.0.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@inglorious/eslint-config": "1.1.2",
|
|
66
|
-
"@reduxjs/toolkit": "^2.
|
|
67
|
-
"prettier": "^3.6
|
|
68
|
-
"vite": "^
|
|
69
|
-
"vitest": "^
|
|
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:
|
|
47
|
+
getEntity: store.getEntity,
|
|
44
48
|
/**
|
|
45
49
|
* Runs a selector against the current state.
|
|
46
50
|
*
|
package/src/client/devtools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deserialize } from "@inglorious/utils/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
22
|
+
const serverUrl = config.serverUrl ?? defaultServerUrl()
|
|
17
23
|
const reconnectionDelay =
|
|
18
24
|
config.reconnectionDelay ?? DEFAULT_RECONNECTION_DELAY
|
|
19
25
|
|
package/src/entities.js
CHANGED
package/src/middlewares.js
CHANGED
package/src/migration/rtk.js
CHANGED
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
|
-
*
|
|
13
|
-
* @
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
307
|
-
|
|
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 {
|
|
3
|
-
import { extend } from "@inglorious/utils/
|
|
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
|
-
|
|
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>
|