@inglorious/store 1.0.0 → 1.1.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/package.json +5 -4
- package/src/entities.js +27 -0
- package/src/entities.test.js +51 -0
- package/src/event-map.js +75 -0
- package/src/event-map.test.js +168 -0
- package/src/store.js +26 -33
- package/src/store.test.js +42 -0
- package/src/types.js +37 -0
- package/src/types.test.js +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/store",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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/entities.js
ADDED
|
@@ -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
|
+
})
|
package/src/event-map.js
ADDED
|
@@ -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,8 +1,9 @@
|
|
|
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.
|
|
8
9
|
* @param {Object} config - Configuration options for the store.
|
|
@@ -18,9 +19,10 @@ export function createStore({
|
|
|
18
19
|
const listeners = new Set()
|
|
19
20
|
let incomingEvents = []
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
const types = augmentTypes(originalTypes)
|
|
23
|
+
const eventMap = new EventMap(types, originalEntities)
|
|
23
24
|
|
|
25
|
+
const entities = augmentEntities(originalEntities)
|
|
24
26
|
const initialState = { entities }
|
|
25
27
|
let state = initialState
|
|
26
28
|
|
|
@@ -66,21 +68,37 @@ 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 =
|
|
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)
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
if (event.type === "remove") {
|
|
79
92
|
const id = event.payload
|
|
93
|
+
const entity = state.entities[id]
|
|
94
|
+
const type = types[entity.type]
|
|
80
95
|
delete state.entities[id]
|
|
96
|
+
|
|
97
|
+
eventMap.removeEntity(id, type)
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
|
|
100
|
+
const entityIds = eventMap.getEntitiesForEvent(event.type)
|
|
101
|
+
for (const id of entityIds) {
|
|
84
102
|
const entity = state.entities[id]
|
|
85
103
|
const type = types[entity.type]
|
|
86
104
|
const handle = type[event.type]
|
|
@@ -148,31 +166,6 @@ export function createStore({
|
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
function reset() {
|
|
151
|
-
state = initialState
|
|
169
|
+
state = initialState
|
|
152
170
|
}
|
|
153
171
|
}
|
|
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
|
-
}
|
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
|
+
})
|