@inglorious/store 1.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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2025 Inglorious Coderz Srl.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # Inglorious Store
2
+
3
+ [![NPM version](https://img.shields.io/npm/v/@inglorious/store.svg)](https://www.npmjs.com/package/@inglorious/store)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ The core, environment-agnostic state management library for the [Inglorious Engine](https://github.com/IngloriousCoderz/inglorious-engine).
7
+
8
+ This package provides a powerful and predictable state container based on an **Entity-Component-System (ECS)** architecture, inspired by **[Redux](https://redux.js.org/)** but tailored for game development. It can be used in any JavaScript environment, including Node.js servers and browsers.
9
+
10
+ ---
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install @inglorious/store
16
+ ```
17
+
18
+ ---
19
+
20
+ ## Core Concepts
21
+
22
+ The state management is built on a few simple principles:
23
+
24
+ 1. **Entities and Properties**: The state is composed of **entities**, which are unique objects. Each entity has a **type** and a set of properties (e.g., `position: [0, 0, 0]`, `health: 100`). Unlike a traditional ECS, properties are not grouped into explicit components.
25
+
26
+ 2. **Types and Behaviors**: The logic for how entities and the overall state change is defined in **types** and **systems**.
27
+ - **Types** are arrays of **behaviors**. A behavior is an object that contains event handlers (e.g., `update(entity, dt) { ... }`). Behaviors are composable, allowing you to define a type by combining multiple sets of properties and event handlers.
28
+ - **Systems** are objects that contain event handlers to operate on the global state or manage interactions between entities.
29
+
30
+ 3. **Events and State Updates**: The only way to change the state is by issuing an **event**. An event is a plain object describing what happened (e.g., `{ type: 'move', payload: { id: 'player1', dx: 1 } }`).
31
+ - The store processes events by first applying behaviors defined on an entity's type, and then running the logic in the systems.
32
+ - An `update` event with a `dt` (delta time) payload is automatically dispatched on every `store.update()` call, making it suitable for a game loop.
33
+
34
+ 4. **Immutability**: The state is immutable. Updates are handled internally by **[Immer](https://immerjs.github.io/immer/)**, so you can "mutate" the state directly within a type's or system's behavior function, and a new, immutable state will be produced.
35
+
36
+ ---
37
+
38
+ ## API
39
+
40
+ ### `createStore(options)`
41
+
42
+ Creates a new store instance.
43
+
44
+ **Parameters:**
45
+
46
+ - `options` (object):
47
+ - `types` (object): A map of entity types. Keys are type names (e.g., `'player'`), and values are arrays of behaviors.
48
+ - `entities` (object): A map of initial entities. Keys are entity IDs, and values are objects containing the entity's properties.
49
+ - `systems` (array, optional): An array of system objects, which define behaviors for the whole state.
50
+
51
+ **Returns:**
52
+
53
+ - A `store` object with the following methods:
54
+ - `subscribe(listener)`: Subscribes a `listener` to state changes. The listener is called after `store.update()` is complete. Returns an `unsubscribe` function.
55
+ - `update(dt, api)`: Processes the event queue and updates the state. This is typically called once per frame. `dt` is the time elapsed since the last frame, and `api` is the engine's public API.
56
+ - `notify(type, payload)`: Adds a new event to the queue to be processed on the next `update` call.
57
+ - `dispatch(event)`: A Redux-compatible alias for `notify`.
58
+ - `getState()`: Returns the current, immutable state.
59
+ - `setState(newState)`: Replaces the entire state with a new one. Use with caution.
60
+ - `getTypes()`: Returns the augmented types configuration. Augmenting here means that the array of behaviors is merged into one single behavior.
61
+ - `getOriginalTypes()`: Returns the original, un-augmented behavior arrays.
62
+ - `reset()`: Resets the state to its initial configuration.
63
+
64
+ ---
65
+
66
+ ### `createSelector(inputSelectors, resultFunc)`
67
+
68
+ Creates a memoized selector to efficiently compute derived data from the state. It only recomputes the result if the inputs to the `resultFunc` have changed.
69
+
70
+ **Parameters:**
71
+
72
+ - `inputSelectors` (array of functions): An array of selector functions that take the state and return a slice of it.
73
+ - `resultFunc` (function): A function that takes the results of the `inputSelectors` and returns the final computed value.
74
+
75
+ **Returns:**
76
+
77
+ - A memoized selector function that takes the `state` as its only argument and returns the selected data.
78
+
79
+ ---
80
+
81
+ ### `createApi(store)`
82
+
83
+ Creates a convenient API object that encapsulates the store's methods and provides common utility functions for accessing state.
84
+
85
+ **Parameters:**
86
+
87
+ - `store` (object): The store instance created with `createStore`.
88
+
89
+ **Returns:**
90
+
91
+ - An `api` object with methods for interacting with the store and state, including:
92
+ - `createSelector(inputSelectors, resultFunc)`: A helper function that automatically binds the store's state to a new selector.
93
+ - `getTypes()`, `getEntities()`, `getEntity(id)`, `getType(id)`: Utility functions for accessing state.
94
+ - `notify(type, payload)`, `dispatch(action)`: Aliases to the store's event dispatching methods.
95
+
96
+ ---
97
+
98
+ ## Basic Usage
99
+
100
+ Here is a simple example of a player entity that moves based on events.
101
+
102
+ ```javascript
103
+ import { createStore, createApi } from "@inglorious/store"
104
+ import { add, scale } from "@inglorious/utils/math/linear-algebra/vectors.js"
105
+
106
+ // 1. Define the behaviors
107
+ const transform = {
108
+ // The second parameter of an event handler is the payload
109
+ move: (entity, payload) => {
110
+ entity.position[0] += payload.dx || 0
111
+ entity.position[1] += payload.dy || 0
112
+ entity.position[2] += payload.dz || 0
113
+ },
114
+ }
115
+
116
+ const kinematic = {
117
+ update: (entity, dt) => {
118
+ // You can use utility functions for easy vector operations
119
+ entity.position = add(entity.position, scale(entity.velocity, dt))
120
+ },
121
+ }
122
+
123
+ // 2. Define the entity types by composing behaviors
124
+ const types = {
125
+ player: [
126
+ // Composed behaviors
127
+ transform,
128
+ kinematic,
129
+ ],
130
+ }
131
+
132
+ // 3. Define the initial entities
133
+ const entities = {
134
+ player1: {
135
+ type: "player",
136
+ position: [0, 0, 0],
137
+ velocity: [0.0625, 0, 0],
138
+ },
139
+ }
140
+
141
+ // 4. Create the store and a unified API
142
+ const store = createStore({ types, entities })
143
+ const api = createApi(store)
144
+
145
+ // 5. Create selectors to get data from the state
146
+ const selectPlayerPosition = api.createSelector(
147
+ [(state) => state.entities.player1],
148
+ (player) => player.position,
149
+ )
150
+
151
+ // 6. Subscribe to changes
152
+ store.subscribe(() => {
153
+ console.log("State updated!", selectPlayerPosition())
154
+ })
155
+
156
+ // 7. Notify the store of an event
157
+ console.log("Initial player position:", selectPlayerPosition()) // => [0, 0, 0]
158
+
159
+ // Dispatch a custom `move` event with a payload
160
+ api.notify("move", { id: "player1", dx: 5, dz: 5 })
161
+
162
+ // Events are queued but not yet processed
163
+ console.log("Position after notify:", selectPlayerPosition()) // => [0, 0, 0]
164
+
165
+ // 8. Run the update loop to process the queue and trigger `update` behaviors
166
+ store.update(16) // Pass delta time
167
+ // Console output from subscriber: "State updated! [6, 0, 5]"
168
+
169
+ console.log("Final position:", selectPlayerPosition()) // => [6, 0, 5]
170
+ ```
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@inglorious/store",
3
+ "version": "1.0.0",
4
+ "description": "A state manager inspired by Redux, but tailored for the specific needs of game development.",
5
+ "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/IngloriousCoderz/inglorious-engine.git"
10
+ },
11
+ "homepage": "https://inglorious-engine.vercel.app/",
12
+ "bugs": {
13
+ "url": "https://github.com/IngloriousCoderz/inglorious-engine/issues"
14
+ },
15
+ "keywords": [
16
+ "functional-programming",
17
+ "gamedev",
18
+ "game-engine",
19
+ "inglorious-engine",
20
+ "state-manager",
21
+ "redux",
22
+ "store"
23
+ ],
24
+ "type": "module",
25
+ "files": [
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "exports": {
31
+ "./*": "./src/*"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "immer": "^10.1.1",
38
+ "@inglorious/utils": "1.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "prettier": "^3.6.2",
42
+ "vite": "^7.1.3",
43
+ "vitest": "^1.6.1"
44
+ },
45
+ "engines": {
46
+ "node": ">= 22"
47
+ },
48
+ "scripts": {
49
+ "format": "prettier --write '**/*.{js,jsx}'",
50
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
51
+ "test:watch": "vitest",
52
+ "test": "vitest run"
53
+ }
54
+ }
package/src/api.js ADDED
@@ -0,0 +1,34 @@
1
+ import { createSelector as _createSelector } from "./select.js"
2
+
3
+ export function createApi(store) {
4
+ const createSelector = (inputSelectors, resultFunc) => {
5
+ const selector = _createSelector(inputSelectors, resultFunc)
6
+ return () => selector(store.getState())
7
+ }
8
+
9
+ const getTypes = () => store.getTypes()
10
+
11
+ const getEntities = () => store.getState().entities
12
+
13
+ const getEntity = (id) => getEntities()[id]
14
+
15
+ const notify = (type, payload) => {
16
+ store.notify(type, payload)
17
+ }
18
+
19
+ const dispatch = (action) => {
20
+ store.dispatch(action)
21
+ }
22
+
23
+ const getType = (id) => store.getOriginalTypes()?.[id]
24
+
25
+ return {
26
+ createSelector,
27
+ getTypes,
28
+ getEntities,
29
+ getEntity,
30
+ getType,
31
+ notify,
32
+ dispatch,
33
+ }
34
+ }
package/src/select.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Creates a memoized selector function.
3
+ * NB: this implementation does not support spreading the input selectors for clarity, please just put them in an array.
4
+ * @param {Array<Function>} inputSelectors - An array of input selector functions.
5
+ * @param {Function} resultFunc - A function that receives the results of the input selectors and returns a computed value.
6
+ * @returns {Function} A memoized selector function that, when called, returns the selected state.
7
+ */
8
+ export function createSelector(inputSelectors, resultFunc) {
9
+ let lastInputs = []
10
+ let lastResult = null
11
+
12
+ return (state) => {
13
+ const nextInputs = inputSelectors.map((selector) => selector(state))
14
+ const inputsChanged =
15
+ lastInputs.length !== nextInputs.length ||
16
+ nextInputs.some((input, index) => input !== lastInputs[index])
17
+
18
+ if (!inputsChanged) {
19
+ return lastResult
20
+ }
21
+
22
+ lastInputs = nextInputs
23
+ lastResult = resultFunc(...nextInputs)
24
+ return lastResult
25
+ }
26
+ }
package/src/store.js ADDED
@@ -0,0 +1,178 @@
1
+ import { map } from "@inglorious/utils/data-structures/object.js"
2
+ import { extend } from "@inglorious/utils/data-structures/objects.js"
3
+ import { pipe } from "@inglorious/utils/functions/functions.js"
4
+ import { produce } from "immer"
5
+
6
+ /**
7
+ * Creates a store to manage state and events.
8
+ * @param {Object} config - Configuration options for the store.
9
+ * @param {Object} [config.types] - The initial types configuration.
10
+ * @param {Object} [config.entities] - The initial entities configuration.
11
+ * @returns {Object} The store with methods to interact with state and events.
12
+ */
13
+ export function createStore({
14
+ types: originalTypes,
15
+ entities: originalEntities,
16
+ systems = [],
17
+ }) {
18
+ const listeners = new Set()
19
+ let incomingEvents = []
20
+
21
+ let types = augmentTypes(originalTypes)
22
+ let entities = augmentEntities(originalEntities)
23
+
24
+ const initialState = { entities }
25
+ let state = initialState
26
+
27
+ return {
28
+ subscribe,
29
+ update,
30
+ notify,
31
+ dispatch, // needed for compatibility with Redux
32
+ getTypes,
33
+ getOriginalTypes,
34
+ getState,
35
+ setState,
36
+ reset,
37
+ }
38
+
39
+ /**
40
+ * Subscribes a listener to state updates.
41
+ * @param {Function} listener - The listener function to call on updates.
42
+ * @returns {Function} A function to unsubscribe the listener.
43
+ */
44
+ function subscribe(listener) {
45
+ listeners.add(listener)
46
+
47
+ return function unsubscribe() {
48
+ listeners.delete(listener)
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Updates the state based on elapsed time and processes events.
54
+ * @param {number} dt - The delta time since the last update in milliseconds.
55
+ * @param {Object} api - The engine's public API.
56
+ */
57
+ function update(dt, api) {
58
+ const processedEvents = []
59
+
60
+ state = produce(state, (state) => {
61
+ incomingEvents.push({ type: "update", payload: dt })
62
+
63
+ while (incomingEvents.length) {
64
+ const event = incomingEvents.shift()
65
+ processedEvents.push(event)
66
+
67
+ if (event.type === "morph") {
68
+ const { id, type } = event.payload
69
+ originalTypes[id] = type
70
+ types = augmentTypes(originalTypes)
71
+ }
72
+
73
+ if (event.type === "add") {
74
+ const { id, ...entity } = event.payload
75
+ state.entities[id] = augmentEntity(id, entity)
76
+ }
77
+
78
+ if (event.type === "remove") {
79
+ const id = event.payload
80
+ delete state.entities[id]
81
+ }
82
+
83
+ for (const id in state.entities) {
84
+ const entity = state.entities[id]
85
+ const type = types[entity.type]
86
+ const handle = type[event.type]
87
+ handle?.(entity, event.payload, api)
88
+ }
89
+
90
+ systems.forEach((system) => {
91
+ const handle = system[event.type]
92
+ handle?.(state, event.payload, api)
93
+ })
94
+ }
95
+ })
96
+
97
+ listeners.forEach((onUpdate) => onUpdate())
98
+
99
+ return processedEvents
100
+ }
101
+
102
+ /**
103
+ * Notifies the store of a new event.
104
+ * @param {string} type - The event object type to notify.
105
+ * @param {any} payload - The event object payload to notify.
106
+ */
107
+ function notify(type, payload) {
108
+ dispatch({ type, payload })
109
+ }
110
+
111
+ /**
112
+ * Dispatches an event to be processed in the next update cycle.
113
+ * @param {Object} event - The event object.
114
+ * @param {string} event.type - The type of the event.
115
+ * @param {any} [event.payload] - The payload of the event.
116
+ */
117
+ function dispatch(event) {
118
+ incomingEvents.push(event)
119
+ }
120
+
121
+ /**
122
+ * Retrieves the augmented types configuration.
123
+ * This includes composed behaviors and event handlers wrapped for immutability.
124
+ * @returns {Object} The augmented types configuration.
125
+ */
126
+ function getTypes() {
127
+ return types
128
+ }
129
+
130
+ /**
131
+ * Retrieves the original, un-augmented types configuration.
132
+ * @returns {Object} The original types configuration.
133
+ */
134
+ function getOriginalTypes() {
135
+ return originalTypes
136
+ }
137
+
138
+ /**
139
+ * Retrieves the current state.
140
+ * @returns {Object} The current state.
141
+ */
142
+ function getState() {
143
+ return state
144
+ }
145
+
146
+ function setState(newState) {
147
+ state = newState
148
+ }
149
+
150
+ function reset() {
151
+ state = initialState // Reset state to its originally computed value
152
+ }
153
+ }
154
+
155
+ function augmentTypes(types) {
156
+ return pipe(applyBehaviors)(types)
157
+ }
158
+
159
+ function applyBehaviors(types) {
160
+ return map(types, (_, type) => {
161
+ if (!Array.isArray(type)) {
162
+ return type
163
+ }
164
+
165
+ const behaviors = type.map((fn) =>
166
+ typeof fn !== "function" ? (type) => extend(type, fn) : fn,
167
+ )
168
+ return pipe(...behaviors)({})
169
+ })
170
+ }
171
+
172
+ function augmentEntities(entities) {
173
+ return map(entities, augmentEntity)
174
+ }
175
+
176
+ function augmentEntity(id, entity) {
177
+ return { ...entity, id }
178
+ }
@@ -0,0 +1,110 @@
1
+ import { expect, test } from "vitest"
2
+
3
+ import { createStore } from "./store.js"
4
+
5
+ test("it should process events by mutating state inside handlers", () => {
6
+ const config = {
7
+ types: {
8
+ kitty: {
9
+ feed(entity) {
10
+ entity.isFed = true
11
+ },
12
+ update(entity) {
13
+ entity.isMeowing = true
14
+ },
15
+ },
16
+ },
17
+ entities: {
18
+ kitty1: { type: "kitty" },
19
+ },
20
+ }
21
+ const afterState = {
22
+ entities: {
23
+ kitty1: {
24
+ id: "kitty1",
25
+ type: "kitty",
26
+ isFed: true,
27
+ isMeowing: true,
28
+ },
29
+ },
30
+ }
31
+
32
+ const store = createStore(config)
33
+ store.notify("feed")
34
+ store.update(0, {})
35
+
36
+ const state = store.getState()
37
+ expect(state).toStrictEqual(afterState)
38
+ })
39
+
40
+ test("it should send an event from an entity and process it in the same update cycle", () => {
41
+ const config = {
42
+ types: {
43
+ doggo: {
44
+ update(entity, dt, api) {
45
+ api.notify("bark")
46
+ },
47
+ },
48
+ kitty: {
49
+ bark(entity) {
50
+ entity.position = "far"
51
+ },
52
+ },
53
+ },
54
+ entities: {
55
+ doggo1: { type: "doggo" },
56
+ kitty1: { type: "kitty", position: "near" },
57
+ },
58
+ }
59
+ const afterState = {
60
+ entities: {
61
+ doggo1: { id: "doggo1", type: "doggo" },
62
+ kitty1: { id: "kitty1", type: "kitty", position: "far" },
63
+ },
64
+ }
65
+
66
+ const store = createStore(config)
67
+ const api = { notify: store.notify }
68
+ store.update(0, api)
69
+
70
+ const state = store.getState()
71
+ expect(state).toStrictEqual(afterState)
72
+ })
73
+
74
+ test("it should add an entity via an 'add' event", () => {
75
+ const config = {
76
+ types: {
77
+ kitty: {},
78
+ },
79
+ entities: {},
80
+ }
81
+ const newEntity = { id: "kitty1", type: "kitty" }
82
+
83
+ const store = createStore(config)
84
+ store.notify("add", newEntity)
85
+ store.update(0, {})
86
+
87
+ const state = store.getState()
88
+ expect(state).toStrictEqual({
89
+ entities: {
90
+ kitty1: { id: "kitty1", type: "kitty" },
91
+ },
92
+ })
93
+ })
94
+
95
+ test("it should remove an entity via a 'remove' event", () => {
96
+ const config = {
97
+ types: {},
98
+ entities: {
99
+ kitty1: { type: "kitty" },
100
+ },
101
+ }
102
+ const store = createStore(config)
103
+
104
+ store.notify("remove", "kitty1")
105
+
106
+ store.update(0, {})
107
+
108
+ const state = store.getState()
109
+ expect(state.entities.kitty1).toBeUndefined()
110
+ })