@inglorious/store 11.2.0 → 12.0.0
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 +144 -19
- package/package.json +24 -4
- package/src/{async.js → behaviors/async.js} +6 -6
- package/src/{optimistic.js → behaviors/optimistic.js} +2 -2
- package/src/behaviors/state-machine/fsm.js +55 -0
- package/src/behaviors/state-machine/scenes.js +89 -0
- package/src/client/multiplayer-middleware.js +12 -12
- package/src/migration/rtk.js +1 -1
- package/src/store.js +121 -32
- package/types/{async.d.ts → behaviors/async.d.ts} +2 -2
- package/types/{optimistic.d.ts → behaviors/optimistic.d.ts} +2 -2
- package/types/behaviors/state-machine/fsm.d.ts +109 -0
- package/types/behaviors/state-machine/scenes.d.ts +53 -0
- package/types/index.d.ts +1 -1
- package/types/store.d.ts +5 -5
package/README.md
CHANGED
|
@@ -400,6 +400,9 @@ The practical use is a world whose rules change with its state — a keyboard wh
|
|
|
400
400
|
mean one thing on a menu and another in play:
|
|
401
401
|
|
|
402
402
|
```javascript
|
|
403
|
+
import { fsm } from "@inglorious/store/fsm"
|
|
404
|
+
import { scenes } from "@inglorious/store/scenes"
|
|
405
|
+
|
|
403
406
|
const types = {
|
|
404
407
|
Keyboard: { keyDown: (entity, key) => report(key) },
|
|
405
408
|
Game: [
|
|
@@ -730,7 +733,7 @@ Inglorious Store now provides utilities to **gradually migrate from RTK slices a
|
|
|
730
733
|
### Converting Async Thunks
|
|
731
734
|
|
|
732
735
|
```javascript
|
|
733
|
-
import { convertAsyncThunk } from "@inglorious/store/rtk"
|
|
736
|
+
import { convertAsyncThunk } from "@inglorious/store/migration/rtk"
|
|
734
737
|
|
|
735
738
|
const fetchTodos = async (userId) => {
|
|
736
739
|
const res = await fetch(`/api/users/${userId}/todos`)
|
|
@@ -765,7 +768,7 @@ const todoList = {
|
|
|
765
768
|
### Converting Slices
|
|
766
769
|
|
|
767
770
|
```javascript
|
|
768
|
-
import { convertSlice } from "@inglorious/store/rtk"
|
|
771
|
+
import { convertSlice } from "@inglorious/store/migration/rtk"
|
|
769
772
|
|
|
770
773
|
const todoListType = convertSlice(todosSlice, {
|
|
771
774
|
asyncThunks: {
|
|
@@ -806,7 +809,7 @@ dispatch({ type: "todos/addTodo", payload: "Buy milk" })
|
|
|
806
809
|
### Migration Guide
|
|
807
810
|
|
|
808
811
|
```javascript
|
|
809
|
-
import { createMigrationGuide } from "@inglorious/store/rtk"
|
|
812
|
+
import { createMigrationGuide } from "@inglorious/store/migration/rtk"
|
|
810
813
|
|
|
811
814
|
const guide = createMigrationGuide(todosSlice)
|
|
812
815
|
console.log(guide)
|
|
@@ -968,7 +971,108 @@ const withLoading = (type) => ({
|
|
|
968
971
|
const form = [resettable, validated, withLoading]
|
|
969
972
|
```
|
|
970
973
|
|
|
971
|
-
When multiple behaviors define the same event,
|
|
974
|
+
When multiple behaviors define the same event, the later one replaces the earlier: a type has one handler per event. That is why a wrapper like `validated` receives the type built so far and calls `type.submit?.(...)` itself, which is how you build middleware-like patterns: validation, logging, error handling, loading states, etc.
|
|
975
|
+
|
|
976
|
+
### 🚦 State Machines
|
|
977
|
+
|
|
978
|
+
Some entities answer the same event differently depending on the state they are in: a door
|
|
979
|
+
opens when it is closed and does nothing when it is locked. `fsm()` turns a type into a
|
|
980
|
+
finite state machine in the manner of the OOP State pattern, where each state says what it
|
|
981
|
+
answers and the entity's `state` field says which state is in charge:
|
|
982
|
+
|
|
983
|
+
```javascript
|
|
984
|
+
import { fsm } from "@inglorious/store/fsm"
|
|
985
|
+
|
|
986
|
+
const types = {
|
|
987
|
+
Door: [
|
|
988
|
+
{
|
|
989
|
+
// What every state does, unless a state says otherwise
|
|
990
|
+
knock(entity) {
|
|
991
|
+
entity.knocks++
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
fsm({
|
|
995
|
+
closed: {
|
|
996
|
+
open(entity) {
|
|
997
|
+
entity.state = "open"
|
|
998
|
+
},
|
|
999
|
+
lock(entity) {
|
|
1000
|
+
entity.state = "locked"
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
open: {
|
|
1004
|
+
close(entity) {
|
|
1005
|
+
entity.state = "closed"
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
locked: {
|
|
1009
|
+
// Takes over knock while locked
|
|
1010
|
+
knock(entity, _, api) {
|
|
1011
|
+
api.notify("bark")
|
|
1012
|
+
},
|
|
1013
|
+
unlock(entity) {
|
|
1014
|
+
entity.state = "closed"
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
}),
|
|
1018
|
+
],
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const entities = { door: { type: "Door", state: "closed", knocks: 0 } }
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
- A state's handler **replaces** the type's handler for the same event, so the type's
|
|
1025
|
+
handler is the default and a state takes it over by naming the event. An event that
|
|
1026
|
+
neither the current state nor the type answers is ignored.
|
|
1027
|
+
- A handler moves the entity by assigning `entity.state`. An entity with no `state` starts
|
|
1028
|
+
in `"default"`.
|
|
1029
|
+
- When a handler has actually moved the entity, `fsm` broadcasts
|
|
1030
|
+
`stateChange` with `{ entityId, from, to }`. It is broadcast rather than addressed to the
|
|
1031
|
+
entity, because what wants to hear it is usually something else, so a listener checks
|
|
1032
|
+
`entityId`.
|
|
1033
|
+
|
|
1034
|
+
Typical uses: request and connection status, wizards, editors that are viewing, editing or
|
|
1035
|
+
saving, media players, drag and drop, and the screens of a game.
|
|
1036
|
+
|
|
1037
|
+
#### Scenes
|
|
1038
|
+
|
|
1039
|
+
`scenes()` is the usual partner of `fsm()`: the machine says when it moves, and `scenes()`
|
|
1040
|
+
says which entities stand in each state. On `create` and on its own `stateChange` it `add`s
|
|
1041
|
+
what the new state has, `remove`s what only the old one had, and `patch`es what both have
|
|
1042
|
+
but configure differently, comparing entities by `id`:
|
|
1043
|
+
|
|
1044
|
+
```javascript
|
|
1045
|
+
import { fsm } from "@inglorious/store/fsm"
|
|
1046
|
+
import { scenes } from "@inglorious/store/scenes"
|
|
1047
|
+
|
|
1048
|
+
const types = {
|
|
1049
|
+
Wizard: [
|
|
1050
|
+
scenes({
|
|
1051
|
+
account: () => [{ id: "accountForm", type: "AccountForm" }],
|
|
1052
|
+
billing: (wizard) => [
|
|
1053
|
+
{ id: "billingForm", type: "BillingForm", plan: wizard.plan },
|
|
1054
|
+
],
|
|
1055
|
+
}),
|
|
1056
|
+
fsm({
|
|
1057
|
+
account: {
|
|
1058
|
+
next(entity) {
|
|
1059
|
+
entity.state = "billing"
|
|
1060
|
+
},
|
|
1061
|
+
},
|
|
1062
|
+
}),
|
|
1063
|
+
],
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const entities = { wizard: { type: "Wizard", state: "account", plan: "pro" } }
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
The entity running the machine keeps what it has up in `scene`.
|
|
1070
|
+
|
|
1071
|
+
The name comes from games, where each screen is a scene, but nothing about it is game-specific.
|
|
1072
|
+
In a web app it gives entities a lifetime tied to a state: a page's forms and lists exist only
|
|
1073
|
+
while you are on it (their `create` and `destroy` can open and close subscriptions), each wizard
|
|
1074
|
+
step has its own form, and a logged-out app has a `loginForm` that a logged-in one does not —
|
|
1075
|
+
while anything both states share, like a header or a cart, survives the move untouched.
|
|
972
1076
|
|
|
973
1077
|
### ⏱️ Batched Mode
|
|
974
1078
|
|
|
@@ -1135,7 +1239,7 @@ const store = createStore({
|
|
|
1135
1239
|
systems, // Array (optional): global state handlers
|
|
1136
1240
|
autoCreateEntities, // Boolean (optional): false (default) or true
|
|
1137
1241
|
updateMode, // String (optional): 'auto' (default) or 'manual'
|
|
1138
|
-
updateStrategy, // String (optional): 'structural-sharing' (default) or '
|
|
1242
|
+
updateStrategy, // String (optional): 'structural-sharing' (default) or 'copy-on-dispatch'
|
|
1139
1243
|
})
|
|
1140
1244
|
```
|
|
1141
1245
|
|
|
@@ -1154,7 +1258,7 @@ const store = createStore({
|
|
|
1154
1258
|
- `'manual'` - Manual control via `api.update()`
|
|
1155
1259
|
- **`updateStrategy`** (optional) - Controls how the state is copied before queued events are applied:
|
|
1156
1260
|
- `'structural-sharing'` (default) - Only the entities that actually changed are copied
|
|
1157
|
-
- `'
|
|
1261
|
+
- `'copy-on-dispatch'` - Each entity is deep-copied as it is handed out, and updated without a proxy
|
|
1158
1262
|
|
|
1159
1263
|
#### Update Strategy
|
|
1160
1264
|
|
|
@@ -1167,10 +1271,26 @@ get copied. Untouched entities keep their previous object reference, which makes
|
|
|
1167
1271
|
reference-equality change detection nearly free. This is what you want for UI stores, where a
|
|
1168
1272
|
handful of entities change per interaction.
|
|
1169
1273
|
|
|
1170
|
-
**`'
|
|
1171
|
-
the copy, with no proxy involved.
|
|
1172
|
-
|
|
1173
|
-
|
|
1274
|
+
**`'copy-on-dispatch'`** deep-copies an entity the first time it is handed to one of its handlers, or read
|
|
1275
|
+
by a system, and applies events directly to the copy, with no proxy involved. Entities that nothing
|
|
1276
|
+
touches during an update are not copied and keep their previous reference. Writing to a copy costs
|
|
1277
|
+
nothing however often it happens, so it scales much better when thousands of entities change every
|
|
1278
|
+
frame — the typical case for a game simulation.
|
|
1279
|
+
|
|
1280
|
+
Systems receive every entity at once, so each entity a system reads through its first argument is
|
|
1281
|
+
copied in case it is written to. A system that only needs to know how things stood should read
|
|
1282
|
+
them from `api.getEntities()` and go through its first argument only for what it writes:
|
|
1283
|
+
|
|
1284
|
+
```javascript
|
|
1285
|
+
const readouts = {
|
|
1286
|
+
update(entities, dt, api) {
|
|
1287
|
+
const current = api.getEntities() // read without copying
|
|
1288
|
+
const count = Object.keys(current).length
|
|
1289
|
+
|
|
1290
|
+
entities.readout.value = `${count} entities` // copies only the readout
|
|
1291
|
+
},
|
|
1292
|
+
}
|
|
1293
|
+
```
|
|
1174
1294
|
|
|
1175
1295
|
```javascript
|
|
1176
1296
|
// A UI store: few entities change, reference equality matters
|
|
@@ -1181,7 +1301,11 @@ const store = createStore({
|
|
|
1181
1301
|
})
|
|
1182
1302
|
|
|
1183
1303
|
// A game simulation: thousands of entities change every frame
|
|
1184
|
-
const store = createStore({
|
|
1304
|
+
const store = createStore({
|
|
1305
|
+
types,
|
|
1306
|
+
entities,
|
|
1307
|
+
updateStrategy: "copy-on-dispatch",
|
|
1308
|
+
})
|
|
1185
1309
|
```
|
|
1186
1310
|
|
|
1187
1311
|
#### State swapping
|
|
@@ -1207,23 +1331,23 @@ const types = {
|
|
|
1207
1331
|
}
|
|
1208
1332
|
```
|
|
1209
1333
|
|
|
1210
|
-
**Caveats for `'
|
|
1334
|
+
**Caveats for `'copy-on-dispatch'`:**
|
|
1211
1335
|
|
|
1212
|
-
-
|
|
1213
|
-
|
|
1214
|
-
`DataCloneError`. Keep such values in module scope or in a closure inside a behavior factory
|
|
1336
|
+
- Plain objects, arrays, vectors and primitives are copied directly. Anything else must be
|
|
1337
|
+
[structured-cloneable](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm):
|
|
1338
|
+
DOM nodes and class instances with non-cloneable internals throw a `DataCloneError`. Keep such values in module scope or in a closure inside a behavior factory
|
|
1215
1339
|
(as the `images()` and `audio()` engine behaviors do) rather than in the state.
|
|
1216
1340
|
- The state is not frozen, even in `devMode`.
|
|
1217
1341
|
- Writing to an entity obtained from `api.getEntity()` during an update mutates the _previous_
|
|
1218
|
-
state
|
|
1219
|
-
|
|
1220
|
-
|
|
1342
|
+
state. Whether the write also survives depends on whether that entity has been copied yet, under
|
|
1343
|
+
either strategy. Always mutate the entity passed to your handler rather than one fetched through
|
|
1344
|
+
the api.
|
|
1221
1345
|
|
|
1222
1346
|
An unsupported strategy throws a `TypeError` at store creation:
|
|
1223
1347
|
|
|
1224
1348
|
```javascript
|
|
1225
1349
|
createStore({ types, entities, updateStrategy: "immutable" })
|
|
1226
|
-
// TypeError: Unsupported update strategy: immutable. Expected one of: structural-sharing,
|
|
1350
|
+
// TypeError: Unsupported update strategy: immutable. Expected one of: structural-sharing, copy-on-dispatch.
|
|
1227
1351
|
```
|
|
1228
1352
|
|
|
1229
1353
|
#### Auto-Create Entities
|
|
@@ -1325,6 +1449,7 @@ Each handler receives three arguments:
|
|
|
1325
1449
|
- **`patch` / `replace`** - change a thing that is already standing, the first touching only
|
|
1326
1450
|
what it names and the second installing all of it
|
|
1327
1451
|
- **`update`** - the frame tick, disabled while the world is paused
|
|
1452
|
+
- **`stateChange`** - broadcast by `fsm()` when an entity moves, with `{ entityId, from, to }`
|
|
1328
1453
|
|
|
1329
1454
|
#### Pausing the world
|
|
1330
1455
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.0.0",
|
|
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",
|
|
@@ -40,9 +40,29 @@
|
|
|
40
40
|
"types": "./types/migration/rtk.d.ts",
|
|
41
41
|
"import": "./src/migration/rtk.js"
|
|
42
42
|
},
|
|
43
|
+
"./async": {
|
|
44
|
+
"types": "./types/behaviors/async.d.ts",
|
|
45
|
+
"import": "./src/behaviors/async.js"
|
|
46
|
+
},
|
|
47
|
+
"./optimistic": {
|
|
48
|
+
"types": "./types/behaviors/optimistic.d.ts",
|
|
49
|
+
"import": "./src/behaviors/optimistic.js"
|
|
50
|
+
},
|
|
51
|
+
"./fsm": {
|
|
52
|
+
"types": "./types/behaviors/state-machine/fsm.d.ts",
|
|
53
|
+
"import": "./src/behaviors/state-machine/fsm.js"
|
|
54
|
+
},
|
|
55
|
+
"./scenes": {
|
|
56
|
+
"types": "./types/behaviors/state-machine/scenes.d.ts",
|
|
57
|
+
"import": "./src/behaviors/state-machine/scenes.js"
|
|
58
|
+
},
|
|
59
|
+
"./*.js": {
|
|
60
|
+
"types": "./types/*.d.ts",
|
|
61
|
+
"import": "./src/*.js"
|
|
62
|
+
},
|
|
43
63
|
"./*": {
|
|
44
64
|
"types": "./types/*.d.ts",
|
|
45
|
-
"import": "./src
|
|
65
|
+
"import": "./src/*.js"
|
|
46
66
|
}
|
|
47
67
|
},
|
|
48
68
|
"files": [
|
|
@@ -55,11 +75,11 @@
|
|
|
55
75
|
},
|
|
56
76
|
"sideEffects": false,
|
|
57
77
|
"dependencies": {
|
|
58
|
-
"@inglorious/utils": "5.
|
|
78
|
+
"@inglorious/utils": "5.1.0",
|
|
59
79
|
"mutative": "^1.3.0"
|
|
60
80
|
},
|
|
61
81
|
"peerDependencies": {
|
|
62
|
-
"@inglorious/utils": "5.
|
|
82
|
+
"@inglorious/utils": "5.1.0"
|
|
63
83
|
},
|
|
64
84
|
"devDependencies": {
|
|
65
85
|
"@inglorious/eslint-config": "1.1.4",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* success, error, and finally. The lifecycle events are automatically scoped based
|
|
6
6
|
* on the provided options.
|
|
7
7
|
*
|
|
8
|
-
* @template {import('
|
|
8
|
+
* @template {import('../store').BaseEntity} TEntity - The entity type
|
|
9
9
|
* @template TPayload - The payload type passed to the operation
|
|
10
10
|
* @template TResult - The result type returned by the async operation
|
|
11
11
|
*
|
|
@@ -13,19 +13,19 @@
|
|
|
13
13
|
* Generated handlers will be named: `type`, `typeStart`, `typeRun`, `typeSuccess`, `typeError`, `typeFinally`
|
|
14
14
|
*
|
|
15
15
|
* @param {Object} handlers - The handler functions for each lifecycle stage.
|
|
16
|
-
* @param {(entity: TEntity, payload: TPayload, api: import('
|
|
16
|
+
* @param {(entity: TEntity, payload: TPayload, api: import('../store').Api) => void} [handlers.start]
|
|
17
17
|
* Called synchronously before the async operation starts.
|
|
18
18
|
* Use for setting loading states. Receives: (entity, payload, api)
|
|
19
|
-
* @param {(payload: TPayload, api: import('
|
|
19
|
+
* @param {(payload: TPayload, api: import('../store').Api) => Promise<TResult> | TResult} handlers.run
|
|
20
20
|
* The async operation to perform. Must return a Promise or value.
|
|
21
21
|
* **Note:** Receives (payload, api) - NOT entity. Entity state should be modified in other handlers.
|
|
22
|
-
* @param {(entity: TEntity, result: TResult, api: import('
|
|
22
|
+
* @param {(entity: TEntity, result: TResult, api: import('../store').Api) => void} [handlers.success]
|
|
23
23
|
* Called when the operation succeeds.
|
|
24
24
|
* Receives: (entity, result, api) where result is the resolved value from run()
|
|
25
|
-
* @param {(entity: TEntity, error: any, api: import('
|
|
25
|
+
* @param {(entity: TEntity, error: any, api: import('../store').Api) => void} [handlers.error]
|
|
26
26
|
* Called when the operation fails.
|
|
27
27
|
* Receives: (entity, error, api) where error is the caught exception
|
|
28
|
-
* @param {(entity: TEntity, api: import('
|
|
28
|
+
* @param {(entity: TEntity, api: import('../store').Api) => void} [handlers.finally]
|
|
29
29
|
* Called after the operation completes (success or failure).
|
|
30
30
|
* Use for cleanup, resetting loading states. Receives: (entity, api)
|
|
31
31
|
*
|
|
@@ -8,13 +8,13 @@ const START = 0
|
|
|
8
8
|
* Pass either a static shallow patch object or a factory that returns one.
|
|
9
9
|
* The wrapper applies the patch during `Start` and rolls it back during `Error`.
|
|
10
10
|
*
|
|
11
|
-
* @template {import('
|
|
11
|
+
* @template {import('../store').BaseEntity} TEntity
|
|
12
12
|
* @template TPayload
|
|
13
13
|
* @template TResult
|
|
14
14
|
* @template {string} TType
|
|
15
15
|
*
|
|
16
16
|
* @param {import('./async').AsyncEvents<TType, TEntity, TPayload, TResult>} behavior
|
|
17
|
-
* @param {Record<string, any> | ((entity: TEntity, payload: TPayload, api: import('
|
|
17
|
+
* @param {Record<string, any> | ((entity: TEntity, payload: TPayload, api: import('../store').Api) => Record<string, any> | null | undefined)} optimistic
|
|
18
18
|
* Shallow patch applied optimistically on start, or a factory that returns it.
|
|
19
19
|
*
|
|
20
20
|
* @returns {import('./async').AsyncEvents<TType, TEntity, TPayload, TResult>}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const DEFAULT_STATE = "default"
|
|
2
|
+
|
|
3
|
+
export function fsm(states) {
|
|
4
|
+
const uniqueEventNames = [
|
|
5
|
+
...new Set(Object.values(states).flatMap(Object.keys)),
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
return (type) => {
|
|
9
|
+
return {
|
|
10
|
+
create(entity, payload, api) {
|
|
11
|
+
type.create?.(entity, payload, api)
|
|
12
|
+
|
|
13
|
+
entity.state ??= DEFAULT_STATE
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
...uniqueEventNames.reduce(
|
|
17
|
+
(acc, eventName) => ({
|
|
18
|
+
...acc,
|
|
19
|
+
|
|
20
|
+
[eventName](entity, event, api) {
|
|
21
|
+
const from = entity.state
|
|
22
|
+
|
|
23
|
+
// A state answers for itself, and what it has to say replaces what the type
|
|
24
|
+
// would have said. The type-stage handler is the default -- the thing every
|
|
25
|
+
// state would otherwise do -- and a state takes it over by naming the same
|
|
26
|
+
// event, which is how a state can take the way out for itself rather than
|
|
27
|
+
// being quit by, without whatever answers by default having to know that
|
|
28
|
+
// state exists.
|
|
29
|
+
const handler = states[entity.state]?.[eventName] ?? type[eventName]
|
|
30
|
+
|
|
31
|
+
handler?.(entity, event, api)
|
|
32
|
+
|
|
33
|
+
announce(entity, from, api)
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
{},
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A machine announces where it has landed, but only when it has actually moved: a
|
|
44
|
+
* handler that reads the state without changing it should not wake anything up.
|
|
45
|
+
*
|
|
46
|
+
* The announcement is broadcast and carries the id, rather than being addressed to
|
|
47
|
+
* this entity, because what wants to hear it is usually something else.
|
|
48
|
+
*/
|
|
49
|
+
function announce(entity, from, api) {
|
|
50
|
+
const to = entity.state
|
|
51
|
+
|
|
52
|
+
if (to === from) return
|
|
53
|
+
|
|
54
|
+
api.notify("stateChange", { entityId: entity.id, from, to })
|
|
55
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { isDeepEqual } from "@inglorious/utils/object.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Puts an entity up, and takes down whatever the state before it had up.
|
|
5
|
+
*
|
|
6
|
+
* A state machine says when it moves; this says what each state is made of, and is
|
|
7
|
+
* usually the pair to `fsm`. Together they cover the ordinary case of a game that is a
|
|
8
|
+
* few screens and a world that survives the move between them -- a menu has nothing on it
|
|
9
|
+
* that the play has, and two states of the same play have all of it in common.
|
|
10
|
+
*
|
|
11
|
+
* Only the difference between two states is touched. Anything they have in common is
|
|
12
|
+
* left standing as it is, which is what carries whatever the two share from one into the
|
|
13
|
+
* next without rebuilding it, and so without losing whatever the world has already done
|
|
14
|
+
* to it.
|
|
15
|
+
*
|
|
16
|
+
* A thing that stands on both sides but is not configured the same way is patched rather
|
|
17
|
+
* than rebuilt, so one state may take over part of it -- a keyboard whose keys mean
|
|
18
|
+
* something else there -- without the rest of it being torn up to make room.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```js
|
|
22
|
+
* import { fsm } from "@inglorious/store/fsm"
|
|
23
|
+
* import { scenes } from "@inglorious/store/scenes"
|
|
24
|
+
*
|
|
25
|
+
* const SCENES = {
|
|
26
|
+
* title: () => [createTitleEntity(), createMenuEntity()],
|
|
27
|
+
* play: () => [createPaddleEntity(), createBallEntity()],
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* const types = {
|
|
31
|
+
* Game: [scenes(SCENES), fsm({ title: { start(e) { e.state = "play" } } })],
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* const entities = { game: { type: "Game", state: "title" } }
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @param {object} scenesByState - What stands in each state, given the state and the
|
|
38
|
+
* entity asking. Returns the entity configurations to have standing.
|
|
39
|
+
* @returns {object} The behaviour.
|
|
40
|
+
*/
|
|
41
|
+
export function scenes(scenesByState) {
|
|
42
|
+
function build(entity, state, api) {
|
|
43
|
+
const leaving = entity.scene ?? []
|
|
44
|
+
const arriving = scenesByState[state]?.(entity) ?? []
|
|
45
|
+
|
|
46
|
+
const leavingIds = leaving.map(({ id }) => id)
|
|
47
|
+
const arrivingIds = arriving.map(({ id }) => id)
|
|
48
|
+
|
|
49
|
+
for (const { id } of leaving) {
|
|
50
|
+
if (arrivingIds.includes(id)) continue
|
|
51
|
+
|
|
52
|
+
api.notify("remove", id)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const added of arriving) {
|
|
56
|
+
if (!leavingIds.includes(added.id)) {
|
|
57
|
+
api.notify("add", added)
|
|
58
|
+
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The same thing stands on both sides of the move, so it is neither added nor
|
|
63
|
+
// removed -- but it may be wanted to be a different thing now, and then only what
|
|
64
|
+
// has actually changed is merged over it. That is what leaves a shared thing where
|
|
65
|
+
// the world had put it when the next state says nothing different about it.
|
|
66
|
+
const previous = leaving.find(({ id }) => id === added.id)
|
|
67
|
+
|
|
68
|
+
if (!isDeepEqual(previous, added)) {
|
|
69
|
+
api.notify("patch", added)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
entity.scene = arriving
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
create(entity, payload, api) {
|
|
78
|
+
build(entity, entity.state, api)
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// The announcement is broadcast and carries the id, so that anything else listening
|
|
82
|
+
// for it can tell which machine moved. This one is only interested in its own.
|
|
83
|
+
stateChange(entity, { entityId, to }, api) {
|
|
84
|
+
if (entityId !== entity.id) return
|
|
85
|
+
|
|
86
|
+
build(entity, to, api)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -2,18 +2,6 @@ import { deserialize, extend, serialize } from "@inglorious/utils/object.js"
|
|
|
2
2
|
|
|
3
3
|
const DEFAULT_RECONNECTION_DELAY = 1000
|
|
4
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
|
-
|
|
17
5
|
/**
|
|
18
6
|
* Creates and returns the multiplayer middleware.
|
|
19
7
|
* @returns {Function} The middleware function.
|
|
@@ -102,3 +90,15 @@ export function multiplayerMiddleware(config = {}) {
|
|
|
102
90
|
}
|
|
103
91
|
}
|
|
104
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Where the server is, when the caller has not said.
|
|
96
|
+
*
|
|
97
|
+
* Read when it is asked for rather than when this module is imported, so that importing it
|
|
98
|
+
* does not require a browser -- which is what lets the store be imported anywhere.
|
|
99
|
+
*
|
|
100
|
+
* @returns {string} The server's WebSocket URL.
|
|
101
|
+
*/
|
|
102
|
+
function defaultServerUrl() {
|
|
103
|
+
return `ws://${window.location.hostname}:3000`
|
|
104
|
+
}
|
package/src/migration/rtk.js
CHANGED
package/src/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clone } from "@inglorious/utils/data-structures/object.js"
|
|
1
2
|
import { toCamelCase } from "@inglorious/utils/data-structures/string.js"
|
|
2
3
|
import { create } from "mutative"
|
|
3
4
|
|
|
@@ -10,10 +11,10 @@ import { augmentType, augmentTypes } from "./types.js"
|
|
|
10
11
|
/** Copies only the entities that change, using a draft proxy. */
|
|
11
12
|
const STRUCTURAL_SHARING = "structural-sharing"
|
|
12
13
|
|
|
13
|
-
/** Deep-
|
|
14
|
-
const
|
|
14
|
+
/** Deep-copies each entity as it is handed out, and applies events to the copy, without a proxy. */
|
|
15
|
+
const COPY_ON_DISPATCH = "copy-on-dispatch"
|
|
15
16
|
|
|
16
|
-
const UPDATE_STRATEGIES = [STRUCTURAL_SHARING,
|
|
17
|
+
const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, COPY_ON_DISPATCH]
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Creates a store to manage state and events.
|
|
@@ -25,10 +26,10 @@ const UPDATE_STRATEGIES = [STRUCTURAL_SHARING, FULL_CLONE]
|
|
|
25
26
|
* that actually changed are copied. Unchanged entities keep their previous reference, which
|
|
26
27
|
* makes change detection cheap for consumers that rely on reference equality. This is the
|
|
27
28
|
* right choice for UI stores, where a small number of entities change at a time.
|
|
28
|
-
* - `"
|
|
29
|
-
* to the copy, without a proxy.
|
|
30
|
-
*
|
|
31
|
-
* entities change every frame, as in a game simulation.
|
|
29
|
+
* - `"copy-on-dispatch"` - Deep-copies each entity the first time it is handed to a handler or read by
|
|
30
|
+
* a system, and applies events directly to the copy, without a proxy. Entities nothing touches
|
|
31
|
+
* are not copied, and there is no per-entity proxy overhead, so it scales better when
|
|
32
|
+
* thousands of entities change every frame, as in a game simulation.
|
|
32
33
|
*
|
|
33
34
|
* Under both strategies the current state is kept intact while the queued events are applied to a
|
|
34
35
|
* separate draft, and the two are swapped only once every event has been processed. Handlers and
|
|
@@ -132,14 +133,14 @@ export function createStore({
|
|
|
132
133
|
let nextState
|
|
133
134
|
|
|
134
135
|
if (updateStrategy === STRUCTURAL_SHARING) {
|
|
135
|
-
nextState = create(state, patch, {
|
|
136
|
+
nextState = create(state, (draft) => patch(writeThrough(draft)), {
|
|
136
137
|
enableAutoFreeze: state.game?.devMode,
|
|
137
138
|
})
|
|
138
139
|
} else {
|
|
139
|
-
const draft =
|
|
140
|
+
const draft = copyOnDispatch(state)
|
|
140
141
|
|
|
141
142
|
patch(draft)
|
|
142
|
-
nextState = draft
|
|
143
|
+
nextState = draft.entities
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
state = nextState
|
|
@@ -152,9 +153,10 @@ export function createStore({
|
|
|
152
153
|
/**
|
|
153
154
|
* Applies every queued event to the given draft.
|
|
154
155
|
*
|
|
155
|
-
* The draft
|
|
156
|
-
*
|
|
157
|
-
* modified, and only becomes the new state once
|
|
156
|
+
* The draft wraps either the proxy returned by `create` (structural sharing) or a
|
|
157
|
+
* copy of the entity map whose entities are copied as they are handed out (copy on
|
|
158
|
+
* dispatch). The current state is not modified, and only becomes the new state once
|
|
159
|
+
* this function returns.
|
|
158
160
|
*
|
|
159
161
|
* A change made by a handler is not visible to the next handler in the same pass:
|
|
160
162
|
* `getState` still returns the state as it was before the pass began. This is on
|
|
@@ -163,7 +165,7 @@ export function createStore({
|
|
|
163
165
|
* left is asking about the past. Anything reacting to a change should be told the
|
|
164
166
|
* change rather than go looking for it.
|
|
165
167
|
*
|
|
166
|
-
* @param {
|
|
168
|
+
* @param {Draft} draft - The draft to apply the queued events to.
|
|
167
169
|
* @returns {void}
|
|
168
170
|
*/
|
|
169
171
|
function patch(draft) {
|
|
@@ -247,24 +249,26 @@ export function createStore({
|
|
|
247
249
|
const halted = isHalted && handlerName === "update"
|
|
248
250
|
|
|
249
251
|
for (const id of entityIds) {
|
|
250
|
-
|
|
252
|
+
// Looked at before it is taken, because an entity the pause holds back is
|
|
253
|
+
// not going to be written to, and taking it would copy it for nothing.
|
|
254
|
+
const entity = draft.entities[id]
|
|
251
255
|
const type = types[entity.type]
|
|
252
256
|
|
|
253
257
|
if (halted && !entity.updatesWhilePaused) continue
|
|
254
258
|
|
|
255
259
|
const handle = type[handlerName]
|
|
256
260
|
|
|
257
|
-
handle?.(
|
|
261
|
+
handle?.(draft.take(id), event.payload, api)
|
|
258
262
|
}
|
|
259
263
|
|
|
260
264
|
if (event.type.endsWith(":destroy")) {
|
|
261
265
|
const [target] = event.type.split(":")
|
|
262
266
|
const [, id] = target.split("#")
|
|
263
|
-
const entity = draft[id]
|
|
267
|
+
const entity = draft.entities[id]
|
|
264
268
|
if (entity) {
|
|
265
269
|
const type = types[entity.type]
|
|
266
270
|
const typeName = entity.type
|
|
267
|
-
delete draft[id]
|
|
271
|
+
delete draft.entities[id]
|
|
268
272
|
|
|
269
273
|
eventMap.removeEntity(id, type, typeName)
|
|
270
274
|
}
|
|
@@ -273,7 +277,7 @@ export function createStore({
|
|
|
273
277
|
// Systems process events by handler name (not scoped)
|
|
274
278
|
systems.forEach((system) => {
|
|
275
279
|
const handle = system[handlerName]
|
|
276
|
-
handle?.(draft, event.payload, api)
|
|
280
|
+
handle?.(draft.forSystems, event.payload, api)
|
|
277
281
|
})
|
|
278
282
|
}
|
|
279
283
|
}
|
|
@@ -423,7 +427,7 @@ export function createStore({
|
|
|
423
427
|
|
|
424
428
|
entitiesToCreate.forEach((id) => {
|
|
425
429
|
const entity = newEntities[id]
|
|
426
|
-
addEntity(state, { id, ...entity })
|
|
430
|
+
addEntity(writeThrough(state), { id, ...entity })
|
|
427
431
|
})
|
|
428
432
|
|
|
429
433
|
entitiesToUpdate.forEach((id) => {
|
|
@@ -445,7 +449,7 @@ export function createStore({
|
|
|
445
449
|
)
|
|
446
450
|
|
|
447
451
|
if (!hasEntity) {
|
|
448
|
-
addEntity(state, { id: entityId, type: typeName })
|
|
452
|
+
addEntity(writeThrough(state), { id: entityId, type: typeName })
|
|
449
453
|
}
|
|
450
454
|
}
|
|
451
455
|
}
|
|
@@ -462,13 +466,13 @@ export function createStore({
|
|
|
462
466
|
|
|
463
467
|
/**
|
|
464
468
|
* Adds an entity to the state and registers its event handlers.
|
|
465
|
-
* @param {
|
|
469
|
+
* @param {Draft} draft - The draft to put it in.
|
|
466
470
|
* @param {Entity} payload - The entity to add, including its id.
|
|
467
471
|
* @returns {void}
|
|
468
472
|
*/
|
|
469
473
|
function addEntity(draft, payload) {
|
|
470
474
|
const { id, ...entity } = payload
|
|
471
|
-
draft
|
|
475
|
+
draft.put(id, augmentEntity(id, entity))
|
|
472
476
|
const type = types[entity.type]
|
|
473
477
|
|
|
474
478
|
eventMap.addEntity(id, type, entity.type)
|
|
@@ -482,7 +486,7 @@ export function createStore({
|
|
|
482
486
|
* as being half a sort, so naming a different one means taking the thing out of the old
|
|
483
487
|
* one's list of entities before putting it in the new one.
|
|
484
488
|
*
|
|
485
|
-
* @param {
|
|
489
|
+
* @param {Draft} draft - The draft it stands in.
|
|
486
490
|
* @param {string} id - The thing being moved.
|
|
487
491
|
* @param {string} previousType - The type it was under.
|
|
488
492
|
* @param {string|undefined} type - The type it is under now, if it has named one.
|
|
@@ -503,20 +507,22 @@ export function createStore({
|
|
|
503
507
|
* new configuration says otherwise. A field named as `undefined` is set to nothing,
|
|
504
508
|
* which is what taking a key off a mapping means.
|
|
505
509
|
*
|
|
506
|
-
* @param {
|
|
510
|
+
* @param {Draft} draft - The draft to change it in.
|
|
507
511
|
* @param {Entity} payload - The thing and the fields to merge into it.
|
|
508
512
|
* @returns {void}
|
|
509
513
|
*/
|
|
510
514
|
function patchEntity(draft, payload) {
|
|
511
515
|
const { id, ...config } = payload
|
|
512
|
-
|
|
516
|
+
// Taken rather than looked at, because the merge keeps whatever the configuration
|
|
517
|
+
// does not mention, and what it keeps must not be shared with the previous state.
|
|
518
|
+
const existing = draft.take(id)
|
|
513
519
|
|
|
514
520
|
// Configuring something that is not there is not a thing that can be done. The world
|
|
515
521
|
// is built in one pass, so a scene naming a thing another scene is yet to put up is
|
|
516
522
|
// ordinary rather than a mistake, and is not worth failing over.
|
|
517
523
|
if (!existing) return
|
|
518
524
|
|
|
519
|
-
draft
|
|
525
|
+
draft.put(id, augmentEntity(id, { ...existing, ...config }))
|
|
520
526
|
|
|
521
527
|
moveToType(draft, id, existing.type, config.type)
|
|
522
528
|
}
|
|
@@ -529,24 +535,25 @@ export function createStore({
|
|
|
529
535
|
* nothing here as much as it is in a patch -- the difference between the two is what
|
|
530
536
|
* happens to the fields neither mentions.
|
|
531
537
|
*
|
|
532
|
-
* @param {
|
|
538
|
+
* @param {Draft} draft - The draft to change it in.
|
|
533
539
|
* @param {Entity} payload - The thing and the fields it is to be.
|
|
534
540
|
* @returns {void}
|
|
535
541
|
*/
|
|
536
542
|
function replaceEntity(draft, payload) {
|
|
537
543
|
const { id, ...config } = payload
|
|
538
|
-
const existing = draft[id]
|
|
544
|
+
const existing = draft.entities[id]
|
|
539
545
|
|
|
540
546
|
if (!existing) return
|
|
541
547
|
|
|
542
|
-
draft
|
|
548
|
+
draft.put(id, augmentEntity(id, config))
|
|
543
549
|
|
|
544
550
|
moveToType(draft, id, existing.type, config.type)
|
|
545
551
|
}
|
|
546
552
|
|
|
547
553
|
/**
|
|
548
554
|
* Queues the destruction of an entity. The entity is removed by the `#id:destroy` event.
|
|
549
|
-
* @param {Entities} draft -
|
|
555
|
+
* @param {Draft|Entities} draft - Where it stands. The removal itself is queued, so
|
|
556
|
+
* nothing is written to it here.
|
|
550
557
|
* @param {string} id - The id of the entity to remove.
|
|
551
558
|
* @returns {void}
|
|
552
559
|
*/
|
|
@@ -555,6 +562,83 @@ export function createStore({
|
|
|
555
562
|
}
|
|
556
563
|
}
|
|
557
564
|
|
|
565
|
+
/**
|
|
566
|
+
* A draft that writes straight into the entities it is given.
|
|
567
|
+
*
|
|
568
|
+
* This is the whole of structural sharing's draft, because Mutative's proxy already copies
|
|
569
|
+
* an entity the moment it is written to, and of the state being built by `setState`,
|
|
570
|
+
* which has no previous state to protect.
|
|
571
|
+
*
|
|
572
|
+
* @param {Entities} entities - The entities to write into.
|
|
573
|
+
* @returns {Draft} The draft.
|
|
574
|
+
*/
|
|
575
|
+
function writeThrough(entities) {
|
|
576
|
+
return {
|
|
577
|
+
entities,
|
|
578
|
+
forSystems: entities,
|
|
579
|
+
take: (id) => entities[id],
|
|
580
|
+
put(id, entity) {
|
|
581
|
+
entities[id] = entity
|
|
582
|
+
},
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* A draft that copies an entity the first time it is handed to anything that could
|
|
588
|
+
* write to it.
|
|
589
|
+
*
|
|
590
|
+
* This is what `copy-on-dispatch` is named after: an entity is copied when an event is
|
|
591
|
+
* dispatched to it, and the entities that nothing touches are never paid for. The map is
|
|
592
|
+
* copied straight away, which is cheap -- a reference for every entity -- and an entity
|
|
593
|
+
* is deep-copied only when it is taken: handed to one of its handlers, merged by a
|
|
594
|
+
* `patch`, or read by a system. A frame on which only the pointer moves copies the
|
|
595
|
+
* pointer, and a frame on which nothing at all happens copies nothing but the map.
|
|
596
|
+
*
|
|
597
|
+
* There is no proxy on the entities themselves, which is what still sets this apart
|
|
598
|
+
* from structural sharing: a handler is given a plain object, and pays nothing for
|
|
599
|
+
* writing to it however many times it does. The one proxy is over the map handed to
|
|
600
|
+
* systems, which have no single entity to be given and so have to be given all of them.
|
|
601
|
+
* Each entity a system reads through it is taken, which means a system that walks every
|
|
602
|
+
* entity copies every entity: one that only needs to know how things stood can ask
|
|
603
|
+
* `api.getEntities()` instead, and take only what it writes to.
|
|
604
|
+
*
|
|
605
|
+
* @param {Entities} state - The state to leave as it is.
|
|
606
|
+
* @returns {Draft} The draft.
|
|
607
|
+
*/
|
|
608
|
+
function copyOnDispatch(state) {
|
|
609
|
+
const entities = { ...state }
|
|
610
|
+
const taken = new Set()
|
|
611
|
+
|
|
612
|
+
function take(id) {
|
|
613
|
+
if (!taken.has(id) && Object.hasOwn(entities, id)) {
|
|
614
|
+
entities[id] = clone(entities[id])
|
|
615
|
+
taken.add(id)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return entities[id]
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function put(id, entity) {
|
|
622
|
+
entities[id] = entity
|
|
623
|
+
taken.add(id)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const forSystems = new Proxy(entities, {
|
|
627
|
+
get(target, key, receiver) {
|
|
628
|
+
return typeof key === "string" && Object.hasOwn(target, key)
|
|
629
|
+
? take(key)
|
|
630
|
+
: Reflect.get(target, key, receiver)
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
set(target, key, value) {
|
|
634
|
+
put(key, value)
|
|
635
|
+
return true
|
|
636
|
+
},
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
return { entities, forSystems, take, put }
|
|
640
|
+
}
|
|
641
|
+
|
|
558
642
|
/**
|
|
559
643
|
* Validates the given update strategy.
|
|
560
644
|
* @param {UpdateStrategy} updateStrategy - The update strategy to validate.
|
|
@@ -572,12 +656,17 @@ function assertUpdateStrategy(updateStrategy) {
|
|
|
572
656
|
}
|
|
573
657
|
|
|
574
658
|
/**
|
|
575
|
-
* @typedef {"structural-sharing" | "
|
|
659
|
+
* @typedef {"structural-sharing" | "copy-on-dispatch"} UpdateStrategy - How the state is copied before events are applied.
|
|
576
660
|
* @typedef {Object} StoreConfig - Configuration options accepted by {@link createStore}.
|
|
577
661
|
* @typedef {Object.<string, any>} Type - An augmented entity type.
|
|
578
662
|
* @typedef {Object.<string, Type>} Types - A collection of augmented types.
|
|
579
663
|
* @typedef {Object.<string, any>} Entity - An object representing an entity.
|
|
580
664
|
* @typedef {Object.<string, Entity>} Entities - A collection of entities indexed by id.
|
|
665
|
+
* @typedef {Object} Draft - The entities an update writes to, and how it gets at them.
|
|
666
|
+
* @property {Entities} Draft.entities - The entities being built, read without copying.
|
|
667
|
+
* @property {Entities} Draft.forSystems - The entities as handed to systems.
|
|
668
|
+
* @property {(id: string) => Entity|undefined} Draft.take - The entity to write to.
|
|
669
|
+
* @property {(id: string, entity: Entity) => void} Draft.put - Sets an entity outright.
|
|
581
670
|
* @typedef {(listener: () => void) => () => void} Listener - Subscribes to state updates and returns an unsubscribe function.
|
|
582
671
|
* @typedef {Object} Store - The store returned by {@link createStore}.
|
|
583
672
|
*/
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Api } from "../../api"
|
|
2
|
+
import type { BaseEntity, EntityType, EventHandler } from "../../store"
|
|
3
|
+
|
|
4
|
+
/** The state an entity is in until a handler moves it. */
|
|
5
|
+
export type DefaultState = "default"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The states an entity can be in: the values of its `state` field, as declared
|
|
9
|
+
* on its interface. An entity that declares no `state` can be in any.
|
|
10
|
+
*/
|
|
11
|
+
export type StateOf<TEntity extends BaseEntity> = TEntity extends {
|
|
12
|
+
state?: infer TState extends string
|
|
13
|
+
}
|
|
14
|
+
? TState
|
|
15
|
+
: string
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What one state answers to: its handlers, by event name. A handler moves the
|
|
19
|
+
* entity by assigning `entity.state`.
|
|
20
|
+
*/
|
|
21
|
+
export type FsmState<
|
|
22
|
+
TEntity extends BaseEntity = BaseEntity,
|
|
23
|
+
TApi extends Api = Api,
|
|
24
|
+
> = {
|
|
25
|
+
[eventName: string]: EventHandler<TEntity, TApi> | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Each state's handlers, by state name. A state that answers nothing of its
|
|
30
|
+
* own can be left out, and then it answers what the type does.
|
|
31
|
+
*/
|
|
32
|
+
export type FsmStates<
|
|
33
|
+
TEntity extends BaseEntity = BaseEntity,
|
|
34
|
+
TApi extends Api = Api,
|
|
35
|
+
> = {
|
|
36
|
+
[TState in StateOf<TEntity> | DefaultState]?: FsmState<TEntity, TApi>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The payload of `stateChange`, which `fsm` broadcasts whenever a handler has
|
|
41
|
+
* moved the entity: the entity's id, the state it left and the one it is in.
|
|
42
|
+
*/
|
|
43
|
+
export interface StateChangePayload<TState extends string = string> {
|
|
44
|
+
entityId: string
|
|
45
|
+
from: TState
|
|
46
|
+
to: TState
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Makes a type a finite state machine, in the manner of the OOP State pattern:
|
|
51
|
+
* what the entity does on an event depends on the state it is in.
|
|
52
|
+
*
|
|
53
|
+
* Each state names the events it answers. A state's handler replaces the
|
|
54
|
+
* type's handler for the same event, so the type's handler is what every state
|
|
55
|
+
* does by default and a state takes it over by naming the event. A handler
|
|
56
|
+
* moves the entity by assigning `entity.state`; the entity starts in its own
|
|
57
|
+
* `state`, or in `"default"` when it has none.
|
|
58
|
+
*
|
|
59
|
+
* After a handler has moved the entity, the machine broadcasts `stateChange`
|
|
60
|
+
* with a `StateChangePayload`. A handler that leaves the state as it was
|
|
61
|
+
* announces nothing.
|
|
62
|
+
*
|
|
63
|
+
* The entity and the api are inferred from where the behavior is used, such as
|
|
64
|
+
* `types: { Door: [Door, fsm({ ... })] }` in a typed store. Declaring `state`
|
|
65
|
+
* on the entity's interface checks the state names as well.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* interface DoorEntity extends BaseEntity {
|
|
70
|
+
* type: "Door"
|
|
71
|
+
* state: "closed" | "open" | "locked"
|
|
72
|
+
* }
|
|
73
|
+
*
|
|
74
|
+
* const types = {
|
|
75
|
+
* Door: [
|
|
76
|
+
* { knock: (entity) => console.log("Nobody's home") },
|
|
77
|
+
* fsm({
|
|
78
|
+
* closed: {
|
|
79
|
+
* open(entity) {
|
|
80
|
+
* entity.state = "open"
|
|
81
|
+
* },
|
|
82
|
+
* lock(entity) {
|
|
83
|
+
* entity.state = "locked"
|
|
84
|
+
* },
|
|
85
|
+
* },
|
|
86
|
+
* open: {
|
|
87
|
+
* close(entity) {
|
|
88
|
+
* entity.state = "closed"
|
|
89
|
+
* },
|
|
90
|
+
* },
|
|
91
|
+
* locked: {
|
|
92
|
+
* unlock(entity) {
|
|
93
|
+
* entity.state = "closed"
|
|
94
|
+
* },
|
|
95
|
+
* },
|
|
96
|
+
* }),
|
|
97
|
+
* ],
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* @param states - Each state's handlers, by state name
|
|
102
|
+
* @returns A behavior that wraps the type it is applied to
|
|
103
|
+
*/
|
|
104
|
+
export function fsm<
|
|
105
|
+
TEntity extends BaseEntity = BaseEntity,
|
|
106
|
+
TApi extends Api = Api,
|
|
107
|
+
>(
|
|
108
|
+
states: FsmStates<TEntity, TApi>,
|
|
109
|
+
): (type: EntityType<TEntity, TApi>) => EntityType<TEntity, TApi>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Api } from "../../api"
|
|
2
|
+
import type { StateOf } from "./fsm"
|
|
3
|
+
import type { BaseEntity, EntityType, Stored } from "../../store"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An entity a scene puts up. It needs its `id`, which is how two scenes tell
|
|
7
|
+
* what they have in common.
|
|
8
|
+
*/
|
|
9
|
+
export type SceneEntity = BaseEntity & {
|
|
10
|
+
id: string
|
|
11
|
+
[field: string]: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What stands in each state, by state name: a function of the entity running
|
|
16
|
+
* the machine that returns the entities to have standing. A state left out has
|
|
17
|
+
* nothing standing.
|
|
18
|
+
*/
|
|
19
|
+
export type ScenesByState<TEntity extends BaseEntity = BaseEntity> = {
|
|
20
|
+
[TState in StateOf<TEntity>]?: (entity: Stored<TEntity>) => SceneEntity[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Puts a state's entities up and takes down whatever the state before it had
|
|
25
|
+
* up. It is usually paired with `fsm`, which says when the state moves: on
|
|
26
|
+
* `create` it builds the entity's current state, and on its own `stateChange`
|
|
27
|
+
* it builds the state it moved to.
|
|
28
|
+
*
|
|
29
|
+
* Only the difference between two states is touched: an entity that is in
|
|
30
|
+
* both is left standing, and is patched rather than rebuilt when the next
|
|
31
|
+
* state configures it differently. Entities are compared by `id`, and the
|
|
32
|
+
* entity running the machine keeps what it has up in `scene`.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const types = {
|
|
37
|
+
* Game: [
|
|
38
|
+
* scenes({
|
|
39
|
+
* title: () => [{ id: "menu", type: "Menu" }],
|
|
40
|
+
* play: () => [{ id: "board", type: "Board" }],
|
|
41
|
+
* }),
|
|
42
|
+
* fsm({ title: { start(entity) { entity.state = "play" } } }),
|
|
43
|
+
* ],
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @param scenesByState - What stands in each state
|
|
48
|
+
* @returns A behavior handling `create` and `stateChange`
|
|
49
|
+
*/
|
|
50
|
+
export function scenes<
|
|
51
|
+
TEntity extends BaseEntity = BaseEntity,
|
|
52
|
+
TApi extends Api = Api,
|
|
53
|
+
>(scenesByState: ScenesByState<TEntity>): EntityType<TEntity, TApi>
|
package/types/index.d.ts
CHANGED
package/types/store.d.ts
CHANGED
|
@@ -154,17 +154,17 @@ export type UpdateMode = "auto" | "manual"
|
|
|
154
154
|
* - `"structural-sharing"` (default) applies events to a draft proxy, so only the entities that
|
|
155
155
|
* actually changed are copied. Unchanged entities keep their previous reference, which keeps
|
|
156
156
|
* reference-equality change detection cheap. Best for UI stores.
|
|
157
|
-
* - `"
|
|
158
|
-
*
|
|
159
|
-
* no per-entity proxy overhead, so it scales better when thousands of
|
|
160
|
-
* frame, as in a game simulation.
|
|
157
|
+
* - `"copy-on-dispatch"` deep-copies each entity the first time it is handed to a handler or read by a
|
|
158
|
+
* system, and applies events to the copy without a proxy. Entities nothing touches are not
|
|
159
|
+
* copied, and there is no per-entity proxy overhead, so it scales better when thousands of
|
|
160
|
+
* entities change every frame, as in a game simulation.
|
|
161
161
|
*
|
|
162
162
|
* Under both strategies the current state is left intact while the queued events are applied to
|
|
163
163
|
* a separate draft, and the two are swapped only once every event has been processed. This means
|
|
164
164
|
* that during an update `api.getEntity()` and `api.getEntities()` read the previous state rather
|
|
165
165
|
* than the in-flight changes.
|
|
166
166
|
*/
|
|
167
|
-
export type UpdateStrategy = "structural-sharing" | "
|
|
167
|
+
export type UpdateStrategy = "structural-sharing" | "copy-on-dispatch"
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
170
|
* Store configuration
|