@inglorious/store 1.0.0 → 1.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
@@ -90,7 +90,7 @@ Creates a convenient API object that encapsulates the store's methods and provid
90
90
 
91
91
  - An `api` object with methods for interacting with the store and state, including:
92
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.
93
+ - `getTypes()`, `getEntities()`, `getEntity(id)`: Utility functions for accessing state.
94
94
  - `notify(type, payload)`, `dispatch(action)`: Aliases to the store's event dispatching methods.
95
95
 
96
96
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/store",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "A state manager inspired by Redux, but tailored for the specific needs of game development.",
5
5
  "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -22,14 +22,15 @@
22
22
  "store"
23
23
  ],
24
24
  "type": "module",
25
+ "exports": {
26
+ ".": "./src/store.js",
27
+ "./*": "./src/*"
28
+ },
25
29
  "files": [
26
30
  "src",
27
31
  "README.md",
28
32
  "LICENSE"
29
33
  ],
30
- "exports": {
31
- "./*": "./src/*"
32
- },
33
34
  "publishConfig": {
34
35
  "access": "public"
35
36
  },
package/src/api.js CHANGED
@@ -12,23 +12,12 @@ export function createApi(store) {
12
12
 
13
13
  const getEntity = (id) => getEntities()[id]
14
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
15
  return {
26
16
  createSelector,
27
17
  getTypes,
28
18
  getEntities,
29
19
  getEntity,
30
- getType,
31
- notify,
32
- dispatch,
20
+ dispatch: store.dispatch,
21
+ notify: store.notify,
33
22
  }
34
23
  }
@@ -0,0 +1,27 @@
1
+ import { map } from "@inglorious/utils/data-structures/object.js"
2
+
3
+ /**
4
+ * @typedef {Object.<string, any>} Entity - An object representing a game entity.
5
+ * @typedef {Object.<string, Entity>} Entities - A collection of named entities.
6
+ */
7
+
8
+ /**
9
+ * Augments a single entity by adding a unique ID.
10
+ *
11
+ * @param {string} id The unique ID for the entity.
12
+ * @param {Entity} entity The raw entity object.
13
+ * @returns {Entity} The augmented entity, including its ID.
14
+ */
15
+ export function augmentEntity(id, entity) {
16
+ return { ...entity, id }
17
+ }
18
+
19
+ /**
20
+ * Augments a collection of raw entities, adding a unique ID to each one.
21
+ *
22
+ * @param {Entities} entities The raw entities to be augmented.
23
+ * @returns {Entities} The augmented entities.
24
+ */
25
+ export function augmentEntities(entities) {
26
+ return map(entities, augmentEntity)
27
+ }
@@ -0,0 +1,51 @@
1
+ import { expect, test } from "vitest"
2
+
3
+ import { augmentEntities, augmentEntity } from "./entities.js"
4
+
5
+ test("augmentEntity should add an id to an entity", () => {
6
+ const entity = { type: "player", health: 100 }
7
+ const result = augmentEntity("player1", entity)
8
+
9
+ expect(result).toStrictEqual({ id: "player1", type: "player", health: 100 })
10
+ })
11
+
12
+ test("augmentEntity should not mutate the original entity", () => {
13
+ const entity = { type: "player" }
14
+ augmentEntity("player1", entity)
15
+
16
+ expect(entity).toStrictEqual({ type: "player" })
17
+ expect(entity).not.toHaveProperty("id")
18
+ })
19
+
20
+ test("augmentEntity should overwrite an existing id property", () => {
21
+ const entity = { id: "oldId", type: "player" }
22
+ const result = augmentEntity("newId", entity)
23
+
24
+ expect(result).toStrictEqual({ id: "newId", type: "player" })
25
+ })
26
+
27
+ test("augmentEntities should augment a collection of entities", () => {
28
+ const entities = {
29
+ player1: { type: "player", health: 100 },
30
+ enemy1: { type: "enemy", damage: 10 },
31
+ }
32
+ const result = augmentEntities(entities)
33
+
34
+ expect(result).toStrictEqual({
35
+ player1: { id: "player1", type: "player", health: 100 },
36
+ enemy1: { id: "enemy1", type: "enemy", damage: 10 },
37
+ })
38
+ })
39
+
40
+ test("augmentEntities should handle an empty collection", () => {
41
+ const result = augmentEntities({})
42
+
43
+ expect(result).toStrictEqual({})
44
+ })
45
+
46
+ test("augmentEntities should not mutate the original entities object", () => {
47
+ const entities = { player1: { type: "player" } }
48
+ augmentEntities(entities)
49
+
50
+ expect(entities.player1).not.toHaveProperty("id")
51
+ })
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @typedef {Object.<string, any>} Type - An object representing an augmented entity type.
3
+ * @typedef {Object.<string, any>} Entity - An object representing a game entity.
4
+ */
5
+
6
+ /**
7
+ * A class to manage the mapping of event names to the entity IDs that handle them.
8
+ * This is used for optimized event handling in a game loop.
9
+ */
10
+ export class EventMap {
11
+ /**
12
+ * Creates an instance of EventMap and initializes it with entities and their types.
13
+ *
14
+ * @param {Object.<string, Type>} types - An object containing all augmented type definitions.
15
+ * @param {Object.<string, Entity>} entities - An object containing all game entities.
16
+ */
17
+ constructor(types, entities) {
18
+ /**
19
+ * The internal map where keys are event names and values are Sets of entity IDs.
20
+ *
21
+ * @type {Object.<string, Set<string>>}
22
+ */
23
+ this.map = {}
24
+
25
+ for (const entityId in entities) {
26
+ const entity = entities[entityId]
27
+ const type = types[entity.type]
28
+ if (type) {
29
+ this.addEntity(entityId, type)
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Adds an entity's ID to the Sets for all event handlers defined in its type.
36
+ * This should be called when a new entity is created or its type is morphed.
37
+ *
38
+ * @param {string} entityId - The ID of the entity.
39
+ * @param {Type} type - The augmented type object of the entity.
40
+ */
41
+ addEntity(entityId, type) {
42
+ for (const eventName in type) {
43
+ if (!this.map[eventName]) {
44
+ this.map[eventName] = new Set()
45
+ }
46
+ this.map[eventName].add(entityId)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Removes an entity's ID from the Sets for all event handlers defined in its type.
52
+ * This should be called when an entity is removed or its type is morphed.
53
+ *
54
+ * @param {string} entityId - The ID of the entity.
55
+ * @param {Type} type - The augmented type object of the entity.
56
+ */
57
+ removeEntity(entityId, type) {
58
+ for (const eventName in type) {
59
+ const entitySet = this.map[eventName]
60
+ if (entitySet) {
61
+ entitySet.delete(entityId)
62
+ }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Retrieves the Set of entity IDs that are subscribed to a given event.
68
+ *
69
+ * @param {string} eventName - The name of the event (e.g., 'update', 'fire').
70
+ * @returns {Set<string>} A Set of entity IDs. Returns an empty Set if no entities are found.
71
+ */
72
+ getEntitiesForEvent(eventName) {
73
+ return this.map[eventName] || new Set()
74
+ }
75
+ }
@@ -0,0 +1,168 @@
1
+ import { performance } from "node:perf_hooks"
2
+
3
+ import { expect, test, vi } from "vitest"
4
+
5
+ import { EventMap } from "./event-map.js"
6
+
7
+ test("constructor should initialize the event map from types and entities", () => {
8
+ const types = {
9
+ player: {
10
+ update: () => {},
11
+ fire: () => {},
12
+ },
13
+ enemy: {
14
+ update: () => {},
15
+ },
16
+ item: {}, // Type with no events
17
+ }
18
+ const entities = {
19
+ player1: { type: "player" },
20
+ player2: { type: "player" },
21
+ enemy1: { type: "enemy" },
22
+ item1: { type: "item" },
23
+ ghost1: { type: "ghost" }, // Type that doesn't exist
24
+ }
25
+
26
+ const eventMap = new EventMap(types, entities)
27
+
28
+ expect(eventMap.getEntitiesForEvent("update")).toStrictEqual(
29
+ new Set(["player1", "player2", "enemy1"]),
30
+ )
31
+ expect(eventMap.getEntitiesForEvent("fire")).toStrictEqual(
32
+ new Set(["player1", "player2"]),
33
+ )
34
+
35
+ // 'item' type has no events, so it shouldn't be in the map
36
+ expect(eventMap.getEntitiesForEvent("item")).toStrictEqual(new Set())
37
+ // 'ghost' type doesn't exist, so it should be ignored
38
+ expect(eventMap.getEntitiesForEvent("ghost")).toStrictEqual(new Set())
39
+ })
40
+
41
+ test("addEntity should add an entity to the correct event sets", () => {
42
+ const types = {
43
+ player: {
44
+ update: () => {},
45
+ jump: () => {},
46
+ },
47
+ }
48
+ const eventMap = new EventMap(types, {})
49
+
50
+ eventMap.addEntity("player1", types.player)
51
+
52
+ expect(eventMap.getEntitiesForEvent("update")).toStrictEqual(
53
+ new Set(["player1"]),
54
+ )
55
+ expect(eventMap.getEntitiesForEvent("jump")).toStrictEqual(
56
+ new Set(["player1"]),
57
+ )
58
+
59
+ // Add another entity of the same type
60
+ eventMap.addEntity("player2", types.player)
61
+ expect(eventMap.getEntitiesForEvent("update")).toStrictEqual(
62
+ new Set(["player1", "player2"]),
63
+ )
64
+ expect(eventMap.getEntitiesForEvent("jump")).toStrictEqual(
65
+ new Set(["player1", "player2"]),
66
+ )
67
+ })
68
+
69
+ test("removeEntity should remove an entity from its event sets", () => {
70
+ const types = {
71
+ player: {
72
+ update: () => {},
73
+ fire: () => {},
74
+ },
75
+ }
76
+ const entities = {
77
+ player1: { type: "player" },
78
+ player2: { type: "player" },
79
+ }
80
+ const eventMap = new EventMap(types, entities)
81
+
82
+ eventMap.removeEntity("player1", types.player)
83
+
84
+ expect(eventMap.getEntitiesForEvent("update")).toStrictEqual(
85
+ new Set(["player2"]),
86
+ )
87
+ expect(eventMap.getEntitiesForEvent("fire")).toStrictEqual(
88
+ new Set(["player2"]),
89
+ )
90
+
91
+ // Removing a non-existent entity should not throw an error
92
+ expect(() => eventMap.removeEntity("player3", types.player)).not.toThrow()
93
+ })
94
+
95
+ test("getEntitiesForEvent should return the correct set of entities for an event", () => {
96
+ const types = {
97
+ player: { update: () => {} },
98
+ enemy: { update: () => {} },
99
+ }
100
+ const entities = {
101
+ player1: { type: "player" },
102
+ enemy1: { type: "enemy" },
103
+ }
104
+ const eventMap = new EventMap(types, entities)
105
+
106
+ const updateEntities = eventMap.getEntitiesForEvent("update")
107
+ expect(updateEntities).toStrictEqual(new Set(["player1", "enemy1"]))
108
+
109
+ const fireEntities = eventMap.getEntitiesForEvent("fire")
110
+ expect(fireEntities).toStrictEqual(new Set())
111
+ })
112
+
113
+ test("EventMap provides a significant performance benefit for event handling", async () => {
114
+ const ENTITY_COUNT = 10000
115
+ const { entities, types } = createTestEntities(ENTITY_COUNT)
116
+ const eventMap = new EventMap(types, entities)
117
+
118
+ // We'll use a mock function to ensure the "work" is consistent
119
+ const updateHandler = vi.fn()
120
+ types.updater.update = updateHandler
121
+
122
+ // --- Simulation A: The Old Way (iterating all entities) ---
123
+ const oldWayStartTime = performance.now()
124
+ for (const id in entities) {
125
+ const entity = entities[id]
126
+ const type = types[entity.type]
127
+ if (type.update) {
128
+ type.update()
129
+ }
130
+ }
131
+ const oldWayTime = performance.now() - oldWayStartTime
132
+
133
+ // Reset the mock for the next simulation
134
+ updateHandler.mockClear()
135
+
136
+ // --- Simulation B: The New Way (using EventMap) ---
137
+ const newWayStartTime = performance.now()
138
+ const updaterIds = eventMap.getEntitiesForEvent("update")
139
+ for (const id of updaterIds) {
140
+ const entity = entities[id]
141
+ const type = types[entity.type]
142
+ type.update()
143
+ }
144
+ const newWayTime = performance.now() - newWayStartTime
145
+
146
+ // Assertions to verify correctness
147
+ expect(oldWayTime).toBeGreaterThan(newWayTime)
148
+ expect(updateHandler).toHaveBeenCalledTimes(updaterIds.size)
149
+ })
150
+
151
+ // Helper function to create a large set of test entities
152
+ function createTestEntities(count) {
153
+ const entities = {}
154
+ const types = {}
155
+ // 10% of entities will have a mock 'update' handler
156
+ const updaterType = { update: vi.fn() }
157
+ const staticType = {}
158
+
159
+ for (let i = 0; i < count; i++) {
160
+ const isUpdater = Math.random() < 0.1
161
+ const typeId = isUpdater ? "updater" : "static"
162
+ entities[`entity-${i}`] = { id: `entity-${i}`, type: typeId }
163
+ }
164
+ types["updater"] = updaterType
165
+ types["static"] = staticType
166
+
167
+ return { entities, types }
168
+ }
package/src/store.js CHANGED
@@ -1,10 +1,12 @@
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
1
  import { produce } from "immer"
5
2
 
3
+ import { augmentEntities, augmentEntity } from "./entities.js"
4
+ import { EventMap } from "./event-map.js"
5
+ import { augmentType, augmentTypes } from "./types.js"
6
+
6
7
  /**
7
8
  * Creates a store to manage state and events.
9
+ *
8
10
  * @param {Object} config - Configuration options for the store.
9
11
  * @param {Object} [config.types] - The initial types configuration.
10
12
  * @param {Object} [config.entities] - The initial entities configuration.
@@ -16,13 +18,11 @@ export function createStore({
16
18
  systems = [],
17
19
  }) {
18
20
  const listeners = new Set()
19
- let incomingEvents = []
20
21
 
21
- let types = augmentTypes(originalTypes)
22
- let entities = augmentEntities(originalEntities)
22
+ const types = augmentTypes(originalTypes)
23
23
 
24
- const initialState = { entities }
25
- let state = initialState
24
+ let state, eventMap, incomingEvents
25
+ reset()
26
26
 
27
27
  return {
28
28
  subscribe,
@@ -38,6 +38,7 @@ export function createStore({
38
38
 
39
39
  /**
40
40
  * Subscribes a listener to state updates.
41
+ *
41
42
  * @param {Function} listener - The listener function to call on updates.
42
43
  * @returns {Function} A function to unsubscribe the listener.
43
44
  */
@@ -51,6 +52,7 @@ export function createStore({
51
52
 
52
53
  /**
53
54
  * Updates the state based on elapsed time and processes events.
55
+ *
54
56
  * @param {number} dt - The delta time since the last update in milliseconds.
55
57
  * @param {Object} api - The engine's public API.
56
58
  */
@@ -66,25 +68,43 @@ export function createStore({
66
68
 
67
69
  if (event.type === "morph") {
68
70
  const { id, type } = event.payload
71
+
72
+ const entity = state.entities[id]
73
+ const oldType = types[entity.type]
74
+
69
75
  originalTypes[id] = type
70
- types = augmentTypes(originalTypes)
76
+ types[id] = augmentType(originalTypes[id])
77
+ const newType = types[id]
78
+
79
+ eventMap.removeEntity(id, oldType)
80
+ eventMap.addEntity(id, newType)
71
81
  }
72
82
 
73
83
  if (event.type === "add") {
74
84
  const { id, ...entity } = event.payload
75
85
  state.entities[id] = augmentEntity(id, entity)
86
+ const type = types[entity.type]
87
+
88
+ eventMap.addEntity(id, type)
89
+ incomingEvents.unshift({ type: "create", payload: id })
76
90
  }
77
91
 
78
92
  if (event.type === "remove") {
79
93
  const id = event.payload
94
+ const entity = state.entities[id]
95
+ const type = types[entity.type]
80
96
  delete state.entities[id]
97
+
98
+ eventMap.removeEntity(id, type)
99
+ incomingEvents.unshift({ type: "destroy", payload: id })
81
100
  }
82
101
 
83
- for (const id in state.entities) {
102
+ const entityIds = eventMap.getEntitiesForEvent(event.type)
103
+ for (const id of entityIds) {
84
104
  const entity = state.entities[id]
85
105
  const type = types[entity.type]
86
106
  const handle = type[event.type]
87
- handle?.(entity, event.payload, api)
107
+ handle(entity, event.payload, api)
88
108
  }
89
109
 
90
110
  systems.forEach((system) => {
@@ -101,6 +121,7 @@ export function createStore({
101
121
 
102
122
  /**
103
123
  * Notifies the store of a new event.
124
+ *
104
125
  * @param {string} type - The event object type to notify.
105
126
  * @param {any} payload - The event object payload to notify.
106
127
  */
@@ -110,6 +131,7 @@ export function createStore({
110
131
 
111
132
  /**
112
133
  * Dispatches an event to be processed in the next update cycle.
134
+ *
113
135
  * @param {Object} event - The event object.
114
136
  * @param {string} event.type - The type of the event.
115
137
  * @param {any} [event.payload] - The payload of the event.
@@ -121,6 +143,7 @@ export function createStore({
121
143
  /**
122
144
  * Retrieves the augmented types configuration.
123
145
  * This includes composed behaviors and event handlers wrapped for immutability.
146
+ *
124
147
  * @returns {Object} The augmented types configuration.
125
148
  */
126
149
  function getTypes() {
@@ -129,6 +152,7 @@ export function createStore({
129
152
 
130
153
  /**
131
154
  * Retrieves the original, un-augmented types configuration.
155
+ *
132
156
  * @returns {Object} The original types configuration.
133
157
  */
134
158
  function getOriginalTypes() {
@@ -137,42 +161,50 @@ export function createStore({
137
161
 
138
162
  /**
139
163
  * Retrieves the current state.
164
+ *
140
165
  * @returns {Object} The current state.
141
166
  */
142
167
  function getState() {
143
168
  return state
144
169
  }
145
170
 
146
- function setState(newState) {
147
- state = newState
148
- }
149
-
150
- function reset() {
151
- state = initialState // Reset state to its originally computed value
152
- }
153
- }
171
+ /**
172
+ * Sets the entire state of the store.
173
+ * This is useful for importing state or setting initial state from a server.
174
+ *
175
+ * @param {Object} nextState - The new state to set.
176
+ */
177
+ function setState(nextState) {
178
+ const oldEntities = state?.entities ?? {}
179
+ const newEntities = augmentEntities(nextState.entities)
154
180
 
155
- function augmentTypes(types) {
156
- return pipe(applyBehaviors)(types)
157
- }
181
+ state = { entities: newEntities }
182
+ eventMap = new EventMap(types, nextState.entities)
183
+ incomingEvents = []
158
184
 
159
- function applyBehaviors(types) {
160
- return map(types, (_, type) => {
161
- if (!Array.isArray(type)) {
162
- return type
163
- }
185
+ const oldEntityIds = new Set(Object.keys(oldEntities))
186
+ const newEntityIds = new Set(Object.keys(newEntities))
164
187
 
165
- const behaviors = type.map((fn) =>
166
- typeof fn !== "function" ? (type) => extend(type, fn) : fn,
188
+ const entitiesToCreate = [...newEntityIds].filter(
189
+ (id) => !oldEntityIds.has(id),
190
+ )
191
+ const entitiesToDestroy = [...oldEntityIds].filter(
192
+ (id) => !newEntityIds.has(id),
167
193
  )
168
- return pipe(...behaviors)({})
169
- })
170
- }
171
194
 
172
- function augmentEntities(entities) {
173
- return map(entities, augmentEntity)
174
- }
195
+ entitiesToCreate.forEach((id) => {
196
+ incomingEvents.push({ type: "create", payload: id })
197
+ })
175
198
 
176
- function augmentEntity(id, entity) {
177
- return { ...entity, id }
199
+ entitiesToDestroy.forEach((id) => {
200
+ incomingEvents.push({ type: "destroy", payload: id })
201
+ })
202
+ }
203
+
204
+ /**
205
+ * Resets the store to its initial state.
206
+ */
207
+ function reset() {
208
+ setState({ entities: originalEntities })
209
+ }
178
210
  }
package/src/store.test.js CHANGED
@@ -108,3 +108,45 @@ test("it should remove an entity via a 'remove' event", () => {
108
108
  const state = store.getState()
109
109
  expect(state.entities.kitty1).toBeUndefined()
110
110
  })
111
+
112
+ test("it should change an entity's behavior via a 'morph' event", () => {
113
+ const Caterpillar = {
114
+ eat(entity) {
115
+ entity.isFull = true
116
+ },
117
+ }
118
+ const Butterfly = {
119
+ fly(entity) {
120
+ entity.hasFlown = true
121
+ },
122
+ }
123
+
124
+ const config = {
125
+ types: {
126
+ bug: Caterpillar,
127
+ },
128
+
129
+ entities: {
130
+ bug: { type: "bug" },
131
+ },
132
+ }
133
+
134
+ const store = createStore(config)
135
+
136
+ store.notify("eat")
137
+ store.update(0, {})
138
+ expect(store.getState()).toStrictEqual({
139
+ entities: {
140
+ bug: { id: "bug", type: "bug", isFull: true },
141
+ },
142
+ })
143
+
144
+ store.notify("morph", { id: "bug", type: [Caterpillar, Butterfly] })
145
+ store.notify("fly")
146
+ store.update(0, {})
147
+ expect(store.getState()).toStrictEqual({
148
+ entities: {
149
+ bug: { id: "bug", type: "bug", isFull: true, hasFlown: true },
150
+ },
151
+ })
152
+ })
package/src/types.js ADDED
@@ -0,0 +1,37 @@
1
+ import { ensureArray } from "@inglorious/utils/data-structures/array.js"
2
+ import { map } from "@inglorious/utils/data-structures/object.js"
3
+ import { extend } from "@inglorious/utils/data-structures/objects.js"
4
+ import { pipe } from "@inglorious/utils/functions/functions.js"
5
+
6
+ /**
7
+ * Augments a single type by composing its behaviors and mixins.
8
+ * If a behavior is an object, it's treated as a mixin and its properties are extended onto the type.
9
+ * If a behavior is a function, it's called with the current type object to apply its logic.
10
+ *
11
+ * @param {Type|AugmentFunction[]} type The raw type definition, which can be an object or an array of mixins/functions.
12
+ * @returns {Type} The fully composed and augmented type object.
13
+ */
14
+ export function augmentType(type) {
15
+ const behaviors = ensureArray(type).map((fn) =>
16
+ typeof fn !== "function" ? (type) => extend(type, fn) : fn,
17
+ )
18
+
19
+ return pipe(...behaviors)({})
20
+ }
21
+
22
+ /**
23
+ * @typedef {Object.<string, any>} Type - An object representing an entity's base type or a behavioral mixin.
24
+ * @typedef {Object.<string, Type>} Types - A collection of named type definitions.
25
+ * @typedef {Function} AugmentFunction - A function that applies augmentations.
26
+ */
27
+
28
+ /**
29
+ * Augments a collection of raw type definitions into a usable format.
30
+ * This process applies all behaviors and mixins to each type.
31
+ *
32
+ * @param {Types} types The raw types to be augmented.
33
+ * @returns {Types} The augmented types, with all behaviors composed.
34
+ */
35
+ export function augmentTypes(types) {
36
+ return map(types, (_, type) => augmentType(type))
37
+ }
@@ -0,0 +1,87 @@
1
+ import { expect, test } from "vitest"
2
+
3
+ import { augmentType, augmentTypes } from "./types.js"
4
+
5
+ test("should handle a single behavior object (mixin)", () => {
6
+ const behavior = { onFire: () => "bang" }
7
+ const result = augmentType(behavior)
8
+
9
+ expect(result).toStrictEqual(behavior)
10
+ // Ensure it's a new object, not the same reference
11
+ expect(result).not.toBe(behavior)
12
+ })
13
+
14
+ test("should compose an array of behavior objects (mixins)", () => {
15
+ const behavior1 = { onFire: () => "bang", onMove: () => "vroom" }
16
+ const behavior2 = { onJump: () => "boing", onFire: () => "pow" } // onFire overwrites
17
+ const result = augmentType([behavior1, behavior2])
18
+
19
+ expect(result).toHaveProperty("onMove")
20
+ expect(result).toHaveProperty("onJump")
21
+ expect(result).toHaveProperty("onFire")
22
+ expect(result.onFire()).toBe("pow")
23
+ })
24
+
25
+ test("should handle a single behavior function", () => {
26
+ const behavior = (type) => ({ ...type, onJump: () => "boing" })
27
+ const result = augmentType(behavior)
28
+
29
+ expect(result).toHaveProperty("onJump")
30
+ expect(result.onJump()).toBe("boing")
31
+ })
32
+
33
+ test("should compose an array of behavior functions (pipe)", () => {
34
+ const behavior1 = (type) => ({ ...type, onMove: () => "vroom" })
35
+ const behavior2 = (type) => ({ ...type, onJump: () => "boing" })
36
+ const result = augmentType([behavior1, behavior2])
37
+
38
+ expect(result).toHaveProperty("onMove")
39
+ expect(result).toHaveProperty("onJump")
40
+ expect(result.onMove()).toBe("vroom")
41
+ expect(result.onJump()).toBe("boing")
42
+ })
43
+
44
+ test("should compose a mixed array of objects and functions", () => {
45
+ const mixin1 = { onFire: () => "bang" }
46
+ const behavior1 = (type) => ({ ...type, onJump: () => "boing" })
47
+ const mixin2 = { onMove: () => "vroom", onFire: () => "pow" } // onFire overwrites
48
+
49
+ const result = augmentType([mixin1, behavior1, mixin2])
50
+
51
+ expect(result).toHaveProperty("onFire")
52
+ expect(result).toHaveProperty("onJump")
53
+ expect(result).toHaveProperty("onMove")
54
+ expect(result.onFire()).toBe("pow")
55
+ expect(result.onJump()).toBe("boing")
56
+ expect(result.onMove()).toBe("vroom")
57
+ })
58
+
59
+ test("should return an empty object for an empty array", () => {
60
+ const result = augmentType([])
61
+ expect(result).toStrictEqual({})
62
+ })
63
+
64
+ test("should handle undefined or null input gracefully", () => {
65
+ expect(augmentType(undefined)).toStrictEqual({})
66
+ expect(augmentType(null)).toStrictEqual({})
67
+ })
68
+
69
+ test("should augment a map of types correctly", () => {
70
+ const types = {
71
+ player: { onJump: () => "boing" },
72
+ enemy: [
73
+ { onMove: () => "stomp" },
74
+ (type) => ({ ...type, onAttack: () => "bite" }),
75
+ ],
76
+ item: [],
77
+ }
78
+
79
+ const result = augmentTypes(types)
80
+
81
+ expect(result.player).toStrictEqual({ onJump: expect.any(Function) })
82
+ expect(result.enemy).toStrictEqual({
83
+ onMove: expect.any(Function),
84
+ onAttack: expect.any(Function),
85
+ })
86
+ expect(result.item).toStrictEqual({})
87
+ })