@inglorious/store 5.0.1 → 5.2.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 CHANGED
@@ -3,11 +3,11 @@
3
3
  [![NPM version](https://img.shields.io/npm/v/@inglorious/store.svg)](https://www.npmjs.com/package/@inglorious/store)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- **State management inspired by video games.**
6
+ **Build apps that are already multiplayer-ready.**
7
7
 
8
- Inglorious Store brings battle-tested patterns from game development to modern web applications. If your app needs real-time updates, multiplayer features, or complex interactive state, you'll benefit from the same techniques that power multiplayer games and collaborative tools like Figma.
8
+ Inglorious Store uses battle-tested patterns from game development to give you an architecture that scales from simple solo apps to real-time collaboration—without refactoring. Start with a basic todo list today. Add collaborative features next year. Same code, zero rewrites.
9
9
 
10
- Perfect for: real-time collaboration, live dashboards, chat apps, interactive visualizations, and any application where state synchronization matters.
10
+ Why settle for state management that wasn't designed for real-time sync? Games solved distributed state synchronization decades ago. Now you can use the same proven patterns for your apps.
11
11
 
12
12
  ---
13
13
 
@@ -128,8 +128,8 @@ const types = {
128
128
  }
129
129
 
130
130
  const entities = {
131
- "counter-1": { type: "counter", value: 0 },
132
- "counter-2": { type: "counter", value: 10 },
131
+ counter1: { type: "counter", value: 0 },
132
+ counter2: { type: "counter", value: 10 },
133
133
  }
134
134
 
135
135
  const store = createStore({ types, entities })
@@ -138,8 +138,8 @@ const store = createStore({ types, entities })
138
138
  store.notify("increment")
139
139
  store.update()
140
140
 
141
- console.log(store.getState().entities["counter-1"].value) // => 1
142
- console.log(store.getState().entities["counter-2"].value) // => 11
141
+ console.log(store.getState().counter1.value) // => 1
142
+ console.log(store.getState().counter2.value) // => 11
143
143
 
144
144
  // To update just one counter, add filtering logic in the handler
145
145
  ```
@@ -233,7 +233,7 @@ store.subscribe(() => {
233
233
 
234
234
  // 6. Dispatch events (use notify or dispatch - both work!)
235
235
  store.notify("inputChange", "Buy milk")
236
- store.notify("formSubmit", store.getState().entities.form.value)
236
+ store.notify("formSubmit", store.getState().form.value)
237
237
  store.notify("toggleClick", 1) // Only todo with id=1 will respond
238
238
  store.notify("filterClick", "active")
239
239
 
@@ -302,8 +302,8 @@ Your state is a collection of **entities** (instances) organized by **type** (li
302
302
 
303
303
  ```javascript
304
304
  const entities = {
305
- "item-1": { type: "cartItem", name: "Shoes", quantity: 1, price: 99 },
306
- "item-2": { type: "cartItem", name: "Shirt", quantity: 2, price: 29 },
305
+ item1: { type: "cartItem", name: "Shoes", quantity: 1, price: 99 },
306
+ item2: { type: "cartItem", name: "Shirt", quantity: 2, price: 29 },
307
307
  }
308
308
  ```
309
309
 
@@ -346,13 +346,13 @@ Events are broadcast to all relevant handlers in a pub/sub pattern.
346
346
 
347
347
  ```javascript
348
348
  // Simplest form - just the entity ID
349
- store.notify("increment", "counter-1")
349
+ store.notify("increment", "counter1")
350
350
 
351
351
  // With additional data
352
- store.notify("applyDiscount", { id: "item-1", percent: 10 })
352
+ store.notify("applyDiscount", { id: "item1", percent: 10 })
353
353
 
354
354
  // Also supports dispatch() for Redux compatibility
355
- store.dispatch({ type: "increment", payload: "counter-1" })
355
+ store.dispatch({ type: "increment", payload: "counter1" })
356
356
 
357
357
  // Process the queue - this is when handlers actually run
358
358
  store.update()
@@ -367,8 +367,8 @@ For global state logic that doesn't belong to a specific entity type.
367
367
  ```javascript
368
368
  const systems = [
369
369
  {
370
- calculateTotal(state) {
371
- state.cartTotal = Object.values(state.entities)
370
+ calculateTotal(entities) {
371
+ state.cartTotal = Object.values(entities)
372
372
  .filter((e) => e.type === "cartItem")
373
373
  .reduce((sum, item) => sum + item.price * item.quantity, 0)
374
374
  },
@@ -415,9 +415,8 @@ Creates a convenience wrapper with utility methods.
415
415
  Create memoized, performant selectors.
416
416
 
417
417
  ```javascript
418
- const selectCompletedTodos = createSelector(
419
- [(state) => state.entities],
420
- (entities) => Object.values(entities).filter((e) => e.completed),
418
+ const selectCompletedTasks = createSelector([(state) => state.tasks], (tasks) =>
419
+ tasks.filter((task) => task.completed),
421
420
  )
422
421
  ```
423
422
 
@@ -572,7 +571,9 @@ This store powers the [Inglorious Engine](https://github.com/IngloriousCoderz/in
572
571
 
573
572
  ## License
574
573
 
575
- MIT
574
+ MIT © [Matteo Antony Mistretta](https://github.com/IngloriousCoderz)
575
+
576
+ This is free and open-source software. Use it however you want!
576
577
 
577
578
  ---
578
579
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "5.0.1",
4
- "description": "A state manager inspired by Redux, but tailored for the specific needs of game development.",
3
+ "version": "5.2.0",
4
+ "description": "A state manager for real-time, collaborative apps, inspired by game development patterns and compatible with Redux.",
5
5
  "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -13,18 +13,26 @@
13
13
  "url": "https://github.com/IngloriousCoderz/inglorious-engine/issues"
14
14
  },
15
15
  "keywords": [
16
- "functional-programming",
17
- "gamedev",
18
- "game-engine",
19
- "inglorious-engine",
20
- "state-manager",
16
+ "state",
17
+ "state-management",
18
+ "store",
21
19
  "redux",
22
- "store"
20
+ "redux-alternative",
21
+ "mutative",
22
+ "immer",
23
+ "collaborative",
24
+ "multiplayer",
25
+ "realtime",
26
+ "event-batching"
23
27
  ],
24
28
  "type": "module",
25
29
  "exports": {
26
- ".": "./src/store.js",
27
- "./*": "./src/*"
30
+ ".": {
31
+ "import": "./src/store.js"
32
+ },
33
+ "./*": {
34
+ "import": "./src/*"
35
+ }
28
36
  },
29
37
  "files": [
30
38
  "src"
@@ -34,10 +42,10 @@
34
42
  },
35
43
  "dependencies": {
36
44
  "mutative": "^1.3.0",
37
- "@inglorious/utils": "3.6.0"
45
+ "@inglorious/utils": "3.6.1"
38
46
  },
39
47
  "peerDependencies": {
40
- "@inglorious/utils": "3.6.0"
48
+ "@inglorious/utils": "3.6.1"
41
49
  },
42
50
  "devDependencies": {
43
51
  "prettier": "^3.6.2",
package/src/api.js CHANGED
@@ -8,7 +8,7 @@ export function createApi(store) {
8
8
 
9
9
  const getTypes = () => store.getTypes()
10
10
 
11
- const getEntities = () => store.getState().entities
11
+ const getEntities = () => store.getState()
12
12
 
13
13
  const getEntity = (id) => getEntities()[id]
14
14
 
@@ -0,0 +1,120 @@
1
+ const LAST_STATE = 1
2
+
3
+ let devToolsInstance = null
4
+ let unsubscribe = null
5
+
6
+ export function connectDevTools(store, config = {}) {
7
+ // Prevent multiple connections
8
+ if (devToolsInstance) {
9
+ return
10
+ }
11
+
12
+ if (typeof window === "undefined" || !window.__REDUX_DEVTOOLS_EXTENSION__) {
13
+ return
14
+ }
15
+
16
+ const name = config.name ?? document.title
17
+ const skippedEvents = config.skippedEvents ?? []
18
+
19
+ devToolsInstance = window.__REDUX_DEVTOOLS_EXTENSION__.connect({
20
+ name,
21
+ predicate: (state, action) => !skippedEvents.includes(action.type),
22
+ // @see https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#features
23
+ features: {
24
+ pause: true, // start/pause recording of dispatched actions
25
+ lock: true, // lock/unlock dispatching actions and side effects
26
+ persist: true, // persist states on page reloading
27
+ export: true, // export history of actions in a file
28
+ import: "custom", // import history of actions from a file
29
+ jump: false, // jump back and forth (time travelling)
30
+ skip: false, // skip (cancel) actions
31
+ reorder: false, // drag and drop actions in the history list
32
+ dispatch: true, // dispatch custom actions or action creators
33
+ test: false, // generate tests for the selected actions
34
+ },
35
+ })
36
+
37
+ unsubscribe = devToolsInstance.subscribe((message) => {
38
+ switch (message.type) {
39
+ case "DISPATCH":
40
+ handleDispatch(message, store)
41
+ break
42
+
43
+ case "ACTION":
44
+ handleAction(message, store)
45
+ break
46
+ }
47
+ })
48
+
49
+ devToolsInstance.init(store.getState())
50
+ }
51
+
52
+ export function disconnectDevTools() {
53
+ // The `disconnect` method on the devToolsInstance is not available in all
54
+ // environments or versions of the extension.
55
+ // The safest way to "disconnect" is to unsubscribe from any listeners
56
+ // and release our reference to the instance, which prevents any further
57
+ // actions from being sent.
58
+ if (unsubscribe) {
59
+ unsubscribe()
60
+ unsubscribe = null
61
+ devToolsInstance = null
62
+ }
63
+ }
64
+
65
+ export function sendAction(action, state) {
66
+ if (devToolsInstance) {
67
+ devToolsInstance.send(action, state)
68
+ }
69
+ }
70
+
71
+ function handleDispatch(message, store) {
72
+ switch (message.payload.type) {
73
+ // reset button
74
+ case "RESET": {
75
+ store.reset()
76
+ devToolsInstance.init(store.getState())
77
+ break
78
+ }
79
+
80
+ // revert button
81
+ case "ROLLBACK": {
82
+ const newState = JSON.parse(message.state)
83
+ store.setState(newState)
84
+ break
85
+ }
86
+
87
+ // commit button
88
+ case "COMMIT": {
89
+ devToolsInstance.init(store.getState())
90
+ break
91
+ }
92
+
93
+ // import from file button
94
+ case "IMPORT_STATE": {
95
+ const { computedStates, actionsById } = message.payload.nextLiftedState
96
+
97
+ const [firstComputedState] = computedStates
98
+ const lastComputedState =
99
+ computedStates[computedStates.length - LAST_STATE]
100
+ if (lastComputedState) {
101
+ store.setState(lastComputedState.state)
102
+ }
103
+
104
+ const flattenedActions = Object.values(actionsById)
105
+ .flatMap(({ action }) => action.payload ?? action)
106
+ .map((action, index) => [index, action])
107
+
108
+ devToolsInstance.init(
109
+ firstComputedState.state,
110
+ Object.fromEntries(flattenedActions),
111
+ )
112
+ break
113
+ }
114
+ }
115
+ }
116
+
117
+ function handleAction(message, store) {
118
+ const action = JSON.parse(message.payload)
119
+ store.dispatch(action)
120
+ }
@@ -0,0 +1,94 @@
1
+ import {
2
+ deserialize,
3
+ serialize,
4
+ } from "@inglorious/utils/data-structures/object.js"
5
+ import { extend } from "@inglorious/utils/data-structures/objects.js"
6
+
7
+ // A constant for the server's WebSocket URL.
8
+ const DEFAULT_SERVER_URL = `ws://${window.location.hostname}:3000`
9
+ const DEFAULT_RECONNECTION_DELAY = 1000
10
+
11
+ /**
12
+ * Creates and returns the multiplayer middleware.
13
+ * @returns {Function} The middleware function.
14
+ */
15
+ export function multiplayerMiddleware(config = {}) {
16
+ const serverUrl = config.serverUrl ?? DEFAULT_SERVER_URL
17
+ const reconnectionDelay =
18
+ config.reconnectionDelay ?? DEFAULT_RECONNECTION_DELAY
19
+ const skippedEvents = config.skippedEvents ?? []
20
+
21
+ let ws = null
22
+ const localQueue = []
23
+
24
+ // The middleware function that will be applied to the store.
25
+ return (store) => (next) => (event) => {
26
+ if (skippedEvents.includes(event.type)) {
27
+ return next(event)
28
+ }
29
+
30
+ // Establish the connection on the first event.
31
+ if (!ws) {
32
+ establishConnection(store)
33
+ }
34
+
35
+ // Only send the event to the server if it didn't come from the server.
36
+ if (!event.fromServer) {
37
+ if (ws?.readyState === WebSocket.OPEN) {
38
+ // If the connection is open, send the event immediately.
39
+ ws.send(serialize(event))
40
+ } else {
41
+ // If the connection is not open, queue the event for later.
42
+ localQueue.push(event)
43
+ }
44
+ }
45
+
46
+ // Pass the event to the next middleware in the chain,
47
+ // which is eventually the store's original dispatch function.
48
+ return next(event)
49
+ }
50
+
51
+ /**
52
+ * Attempts to establish a WebSocket connection to the server.
53
+ */
54
+ function establishConnection(store) {
55
+ // If a connection already exists, close it first.
56
+ if (ws) {
57
+ ws.close()
58
+ }
59
+ ws = new WebSocket(serverUrl)
60
+
61
+ // =====================================================================
62
+ // WebSocket Event Handlers
63
+ // =====================================================================
64
+
65
+ ws.onopen = () => {
66
+ // Send any queued events to the server.
67
+ while (localQueue.length) {
68
+ ws.send(serialize(localQueue.shift()))
69
+ }
70
+ }
71
+
72
+ ws.onmessage = (event) => {
73
+ const serverEvent = deserialize(event.data)
74
+
75
+ if (serverEvent.type === "initialState") {
76
+ // Merge the server's initial state with the client's local state.
77
+ const nextState = extend(store.getState(), serverEvent.payload)
78
+ store.setState(nextState)
79
+ } else {
80
+ // Dispatch the event to the local store to update the client's state.
81
+ store.dispatch({ ...serverEvent, fromServer: true })
82
+ }
83
+ }
84
+
85
+ ws.onclose = () => {
86
+ // Attempt to reconnect after a short delay.
87
+ setTimeout(() => establishConnection(store), reconnectionDelay)
88
+ }
89
+
90
+ ws.onerror = () => {
91
+ ws.close() // The 'onclose' handler will trigger the reconnect.
92
+ }
93
+ }
94
+ }
package/src/store.js CHANGED
@@ -68,7 +68,7 @@ export function createStore({
68
68
  const processedEvents = []
69
69
 
70
70
  state = create(state, patcher, {
71
- enableAutoFreeze: state.entities.game?.devMode,
71
+ enableAutoFreeze: state.game?.devMode,
72
72
  })
73
73
 
74
74
  listeners.forEach((onUpdate) => onUpdate())
@@ -83,7 +83,7 @@ export function createStore({
83
83
  if (event.type === "morph") {
84
84
  const { id, type } = event.payload
85
85
 
86
- const entity = draft.entities[id]
86
+ const entity = draft[id]
87
87
  const oldType = types[entity.type]
88
88
 
89
89
  originalTypes[id] = type
@@ -96,7 +96,7 @@ export function createStore({
96
96
 
97
97
  if (event.type === "add") {
98
98
  const { id, ...entity } = event.payload
99
- draft.entities[id] = augmentEntity(id, entity)
99
+ draft[id] = augmentEntity(id, entity)
100
100
  const type = types[entity.type]
101
101
 
102
102
  eventMap.addEntity(id, type)
@@ -105,9 +105,9 @@ export function createStore({
105
105
 
106
106
  if (event.type === "remove") {
107
107
  const id = event.payload
108
- const entity = draft.entities[id]
108
+ const entity = draft[id]
109
109
  const type = types[entity.type]
110
- delete draft.entities[id]
110
+ delete draft[id]
111
111
 
112
112
  eventMap.removeEntity(id, type)
113
113
  incomingEvents.unshift({ type: "destroy", payload: id })
@@ -115,7 +115,7 @@ export function createStore({
115
115
 
116
116
  const entityIds = eventMap.getEntitiesForEvent(event.type)
117
117
  for (const id of entityIds) {
118
- const entity = draft.entities[id]
118
+ const entity = draft[id]
119
119
  const type = types[entity.type]
120
120
  const handle = type[event.type]
121
121
  handle(entity, event.payload, api)
@@ -187,11 +187,11 @@ export function createStore({
187
187
  * @param {Object} nextState - The new state to set.
188
188
  */
189
189
  function setState(nextState) {
190
- const oldEntities = state?.entities ?? {}
191
- const newEntities = augmentEntities(nextState.entities)
190
+ const oldEntities = state ?? {}
191
+ const newEntities = augmentEntities(nextState)
192
192
 
193
- state = { entities: newEntities }
194
- eventMap = new EventMap(types, nextState.entities)
193
+ state = newEntities
194
+ eventMap = new EventMap(types, nextState)
195
195
  incomingEvents = []
196
196
 
197
197
  const oldEntityIds = new Set(Object.keys(oldEntities))
@@ -217,6 +217,6 @@ export function createStore({
217
217
  * Resets the store to its initial state.
218
218
  */
219
219
  function reset() {
220
- setState({ entities: originalEntities })
220
+ setState(originalEntities)
221
221
  }
222
222
  }
package/src/store.test.js CHANGED
@@ -16,12 +16,10 @@ test("it should process events by mutating state inside handlers", () => {
16
16
  },
17
17
  }
18
18
  const afterState = {
19
- entities: {
20
- kitty1: {
21
- id: "kitty1",
22
- type: "kitty",
23
- isFed: true,
24
- },
19
+ kitty1: {
20
+ id: "kitty1",
21
+ type: "kitty",
22
+ isFed: true,
25
23
  },
26
24
  }
27
25
 
@@ -50,13 +48,11 @@ test("it should process an event queue in the same update cycle", () => {
50
48
  },
51
49
  }
52
50
  const afterState = {
53
- entities: {
54
- kitty1: {
55
- id: "kitty1",
56
- type: "kitty",
57
- isFed: true,
58
- isMeowing: true,
59
- },
51
+ kitty1: {
52
+ id: "kitty1",
53
+ type: "kitty",
54
+ isFed: true,
55
+ isMeowing: true,
60
56
  },
61
57
  }
62
58
 
@@ -89,10 +85,8 @@ test("it should send an event from an entity and process it in the same update c
89
85
  },
90
86
  }
91
87
  const afterState = {
92
- entities: {
93
- doggo1: { id: "doggo1", type: "doggo" },
94
- kitty1: { id: "kitty1", type: "kitty", position: "far" },
95
- },
88
+ doggo1: { id: "doggo1", type: "doggo" },
89
+ kitty1: { id: "kitty1", type: "kitty", position: "far" },
96
90
  }
97
91
 
98
92
  const store = createStore(config)
@@ -112,17 +106,16 @@ test("it should add an entity via an 'add' event", () => {
112
106
  entities: {},
113
107
  }
114
108
  const newEntity = { id: "kitty1", type: "kitty" }
109
+ const afterState = {
110
+ kitty1: { id: "kitty1", type: "kitty" },
111
+ }
115
112
 
116
113
  const store = createStore(config)
117
114
  store.notify("add", newEntity)
118
115
  store.update()
119
116
 
120
117
  const state = store.getState()
121
- expect(state).toStrictEqual({
122
- entities: {
123
- kitty1: { id: "kitty1", type: "kitty" },
124
- },
125
- })
118
+ expect(state).toStrictEqual(afterState)
126
119
  })
127
120
 
128
121
  test("it should remove an entity via a 'remove' event", () => {
@@ -138,7 +131,7 @@ test("it should remove an entity via a 'remove' event", () => {
138
131
  store.update()
139
132
 
140
133
  const state = store.getState()
141
- expect(state.entities.kitty1).toBeUndefined()
134
+ expect(state.kitty1).toBeUndefined()
142
135
  })
143
136
 
144
137
  test("it should change an entity's behavior via a 'morph' event", () => {
@@ -169,17 +162,13 @@ test("it should change an entity's behavior via a 'morph' event", () => {
169
162
  store.update()
170
163
 
171
164
  expect(store.getState()).toStrictEqual({
172
- entities: {
173
- bug: { id: "bug", type: "bug", isFull: true },
174
- },
165
+ bug: { id: "bug", type: "bug", isFull: true },
175
166
  })
176
167
 
177
168
  store.notify("morph", { id: "bug", type: [Caterpillar, Butterfly] })
178
169
  store.notify("fly")
179
170
  store.update()
180
171
  expect(store.getState()).toStrictEqual({
181
- entities: {
182
- bug: { id: "bug", type: "bug", isFull: true, hasFlown: true },
183
- },
172
+ bug: { id: "bug", type: "bug", isFull: true, hasFlown: true },
184
173
  })
185
174
  })