@duyquangnvx/state-machine 0.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/README.md ADDED
@@ -0,0 +1,240 @@
1
+ # state-machine
2
+
3
+ A generic, type-safe finite state machine library for TypeScript with synchronous lifecycle hooks, transition guards, hierarchical states, event history, and a tick-based update loop.
4
+
5
+ ## Features
6
+
7
+ - **Fully generic** — `StateMachine<TContext, TStateId>` works with any context and state ID types
8
+ - **Synchronous lifecycle** — `onEnter` / `onExit` / `onUpdate` are all sync for deterministic state at every moment
9
+ - **Transition guards** — `canTransitionTo(target, ctx)` lets each state control allowed transitions
10
+ - **Tick-based updates** — `onUpdate(ctx, dt)` runs every frame/tick; return a state ID to auto-transition
11
+ - **Hierarchical states** — `HierarchicalState` embeds a nested state machine inside a parent state
12
+ - **Event system** — Subscribe to state changes with `on(listener)`, unsubscribe with the returned function
13
+ - **Bounded history** — Configurable history buffer for debugging and replay
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ npm install
19
+ npm run build
20
+ ```
21
+
22
+ ### Define states
23
+
24
+ ```ts
25
+ import { BaseState } from "state-machine";
26
+
27
+ type MyStateId = "idle" | "loading" | "ready";
28
+
29
+ interface MyContext {
30
+ data: string | null;
31
+ }
32
+
33
+ class IdleState extends BaseState<MyContext, MyStateId> {
34
+ readonly id = "idle" as const;
35
+
36
+ override onEnter(ctx: MyContext, prevState: MyStateId | null): void {
37
+ console.log("Entered idle");
38
+ }
39
+
40
+ override onUpdate(ctx: MyContext, dt: number): MyStateId | undefined {
41
+ return "loading"; // auto-transition on next tick
42
+ }
43
+ }
44
+
45
+ class LoadingState extends BaseState<MyContext, MyStateId> {
46
+ readonly id = "loading" as const;
47
+
48
+ override onUpdate(): MyStateId | undefined {
49
+ return "ready";
50
+ }
51
+ }
52
+
53
+ class ReadyState extends BaseState<MyContext, MyStateId> {
54
+ readonly id = "ready" as const;
55
+ }
56
+ ```
57
+
58
+ ### Create and run the machine
59
+
60
+ ```ts
61
+ import { StateMachine } from "state-machine";
62
+
63
+ const sm = new StateMachine<MyContext, MyStateId>({
64
+ states: [new IdleState(), new LoadingState(), new ReadyState()],
65
+ initialState: "idle",
66
+ context: { data: null },
67
+ historySize: 50, // optional, defaults to 100
68
+ });
69
+
70
+ sm.start(); // enters "idle", calls onEnter
71
+ sm.update(0.016); // calls onUpdate, may auto-transition
72
+ sm.transitionTo("ready"); // explicit transition
73
+ sm.stop(); // exits current state, calls onExit
74
+ ```
75
+
76
+ ### Transition guards
77
+
78
+ Override `canTransitionTo` to block transitions conditionally:
79
+
80
+ ```ts
81
+ class IdleState extends BaseState<MyContext, MyStateId> {
82
+ readonly id = "idle" as const;
83
+
84
+ override canTransitionTo(target: MyStateId, ctx: MyContext): boolean {
85
+ return ctx.data !== null; // only allow transitions when data is loaded
86
+ }
87
+ }
88
+ ```
89
+
90
+ Blocked transitions throw `TransitionDeniedError`.
91
+
92
+ ### Events and history
93
+
94
+ ```ts
95
+ const unsub = sm.on((event) => {
96
+ console.log(`${event.from} -> ${event.to} at ${event.timestamp}`);
97
+ });
98
+
99
+ sm.getHistory(); // ReadonlyArray<StateChangeEvent<MyStateId>>
100
+ unsub(); // stop listening
101
+ ```
102
+
103
+ ### Async work as a state
104
+
105
+ The state machine is fully synchronous — `onEnter`, `onExit`, and `onUpdate` all return `void`. This ensures the machine is always in exactly one definite state at any moment, which is critical for game loops and real-time systems.
106
+
107
+ To handle async operations (API calls, loading, etc.), **model the async work as its own state** that polls for completion:
108
+
109
+ ```ts
110
+ type MyStateId = "IDLE" | "LOADING" | "READY";
111
+
112
+ interface MyContext {
113
+ loading: boolean;
114
+ data: string | null;
115
+ fetchData: () => Promise<string>;
116
+ }
117
+
118
+ class LoadingState extends BaseState<MyContext, MyStateId> {
119
+ readonly id = "LOADING" as const;
120
+
121
+ override onEnter(ctx: MyContext): void {
122
+ ctx.loading = true;
123
+ ctx.fetchData().then((data) => {
124
+ ctx.data = data;
125
+ ctx.loading = false;
126
+ });
127
+ }
128
+
129
+ override onUpdate(ctx: MyContext): MyStateId | undefined {
130
+ if (!ctx.loading) return "READY";
131
+ return undefined;
132
+ }
133
+ }
134
+ ```
135
+
136
+ This pattern keeps the state machine synchronous while still supporting async operations. The state machine ticks on each frame/update, and the loading state simply polls until the async work completes.
137
+
138
+ ## API
139
+
140
+ ### `StateMachine<TContext, TStateId>`
141
+
142
+ | Method / Property | Description |
143
+ |---|---|
144
+ | `start(): void` | Enter the initial state. Idempotent. |
145
+ | `stop(): void` | Exit the current state and shut down. |
146
+ | `transitionTo(stateId): void` | Transition to a specific state (checks guard). |
147
+ | `update(dt): void` | Tick the current state; auto-transitions if `onUpdate` returns a state ID. |
148
+ | `on(listener)` | Subscribe to state changes. Returns unsubscribe function. |
149
+ | `getHistory()` | Get the bounded transition history. |
150
+ | `currentStateId` | The active state's ID. Throws if not started. |
151
+ | `isStarted` | Whether the machine is running. |
152
+ | `context` | The shared mutable context object. |
153
+
154
+ ### `BaseState<TContext, TStateId>` (lifecycle hooks)
155
+
156
+ | Hook | Signature | Notes |
157
+ |---|---|---|
158
+ | `canTransitionTo` | `(target, ctx) => boolean` | Sync guard. Default: `true`. |
159
+ | `onEnter` | `(ctx, prevState) => void` | Called when entering. `prevState` is `null` on `start()`. |
160
+ | `onUpdate` | `(ctx, dt) => TStateId \| undefined` | Return a state ID to auto-transition. |
161
+ | `onExit` | `(ctx, nextState) => void` | Called when leaving. `nextState` is `null` on `stop()`. |
162
+
163
+ ### `HierarchicalState<TContext, TParentId, TChildId>`
164
+
165
+ A composite state that runs a nested `StateMachine`. Override `createChildConfig(ctx)` to define the child machine. The child starts/stops automatically with the parent state.
166
+
167
+ ### Errors
168
+
169
+ | Error | Thrown when |
170
+ |---|---|
171
+ | `StateNotFoundError` | Transitioning to an unknown state ID. |
172
+ | `MachineNotStartedError` | Calling `transitionTo`, `update`, or `currentStateId` before `start()`. |
173
+ | `TransitionDeniedError` | `canTransitionTo` returns `false`. |
174
+
175
+ ## Demos
176
+
177
+ ### Tower Defense
178
+
179
+ Three interlocking state machines (tower, enemy, wave) simulating a tower defense game loop.
180
+
181
+ ```
182
+ Tower: BUILDING -> IDLE -> TARGETING -> ATTACKING -> IDLE
183
+ Enemy: SPAWNING -> MOVING -> ATTACKING -> DYING -> DEAD
184
+ Wave: PREPARING -> WAVE_ACTIVE -> WAVE_COMPLETE -> ... -> GAME_OVER
185
+ ```
186
+
187
+ ```bash
188
+ npm run build && npm run demo
189
+ ```
190
+
191
+ ### Slot Machine
192
+
193
+ Demonstrates the "async work as a state" pattern — API calls (bet deduction, payout crediting) are modeled as dedicated states that poll for completion.
194
+
195
+ ```
196
+ IDLE -> DEDUCTING_BET -> SPINNING -> STOPPING -> EVALUATING -> CREDITING_WIN -> IDLE
197
+ |
198
+ +--> IDLE (no win)
199
+ ```
200
+
201
+ ```bash
202
+ npm run build && npm run demo:slot
203
+ ```
204
+
205
+ ## Testing
206
+
207
+ ```bash
208
+ npm test
209
+ ```
210
+
211
+ 67 tests covering the core library (construction, start/stop, transitions, guards, updates, events, history, hierarchical states) and both demos.
212
+
213
+ ## Project structure
214
+
215
+ ```
216
+ src/
217
+ lib/ # Core library
218
+ State.ts # BaseState abstract class
219
+ StateMachine.ts # StateMachine engine
220
+ HierarchicalState.ts # Composite state with nested machine
221
+ StateEvent.ts # Event emitter with bounded history
222
+ interfaces.ts # IState, IStateMachine, config types
223
+ errors.ts # StateNotFoundError, MachineNotStartedError, TransitionDeniedError
224
+ index.ts # Public exports
225
+ demo/
226
+ tower/ # Tower defense FSM (tower states)
227
+ enemy/ # Tower defense FSM (enemy states)
228
+ wave/ # Tower defense FSM (wave management)
229
+ slot/ # Slot machine FSM
230
+ main.ts # Tower defense entry point
231
+ slot-main.ts # Slot machine entry point
232
+ GameLoop.ts # Tick-based game loop utility
233
+ tests/
234
+ lib/ # Core library tests
235
+ demo/ # Demo-specific tests
236
+ ```
237
+
238
+ ## License
239
+
240
+ ISC
package/dist/index.cjs ADDED
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/lib/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ BaseState: () => BaseState,
24
+ HierarchicalState: () => HierarchicalState,
25
+ MachineNotStartedError: () => MachineNotStartedError,
26
+ StateEventEmitter: () => StateEventEmitter,
27
+ StateMachine: () => StateMachine,
28
+ StateNotFoundError: () => StateNotFoundError,
29
+ TransitionDeniedError: () => TransitionDeniedError
30
+ });
31
+ module.exports = __toCommonJS(index_exports);
32
+
33
+ // src/lib/State.ts
34
+ var BaseState = class {
35
+ canTransitionTo(_targetState, _ctx) {
36
+ return true;
37
+ }
38
+ onEnter(_ctx, _prevState) {
39
+ }
40
+ onUpdate(_ctx, _dt) {
41
+ return void 0;
42
+ }
43
+ onExit(_ctx, _nextState) {
44
+ }
45
+ };
46
+
47
+ // src/lib/StateEvent.ts
48
+ var StateEventEmitter = class {
49
+ listeners = [];
50
+ history = [];
51
+ maxHistory;
52
+ constructor(maxHistory = 100) {
53
+ this.maxHistory = maxHistory;
54
+ }
55
+ on(listener) {
56
+ this.listeners.push(listener);
57
+ return () => {
58
+ const idx = this.listeners.indexOf(listener);
59
+ if (idx !== -1) this.listeners.splice(idx, 1);
60
+ };
61
+ }
62
+ emit(change) {
63
+ this.history.push(change);
64
+ if (this.history.length > this.maxHistory) {
65
+ this.history.shift();
66
+ }
67
+ for (const listener of this.listeners) {
68
+ listener(change);
69
+ }
70
+ }
71
+ getHistory() {
72
+ return [...this.history];
73
+ }
74
+ clear() {
75
+ this.history.length = 0;
76
+ this.listeners.length = 0;
77
+ }
78
+ };
79
+
80
+ // src/lib/errors.ts
81
+ var StateNotFoundError = class extends Error {
82
+ constructor(stateId) {
83
+ super(`State not found: "${stateId}"`);
84
+ this.name = "StateNotFoundError";
85
+ }
86
+ };
87
+ var MachineNotStartedError = class extends Error {
88
+ constructor() {
89
+ super("State machine has not been started. Call start() first.");
90
+ this.name = "MachineNotStartedError";
91
+ }
92
+ };
93
+ var TransitionDeniedError = class extends Error {
94
+ constructor(from, to) {
95
+ super(`Transition denied: "${from}" -> "${to}"`);
96
+ this.name = "TransitionDeniedError";
97
+ }
98
+ };
99
+
100
+ // src/lib/StateMachine.ts
101
+ var StateMachine = class {
102
+ stateMap = /* @__PURE__ */ new Map();
103
+ emitter;
104
+ currentState = null;
105
+ initialStateId;
106
+ _isStarted = false;
107
+ context;
108
+ constructor(config) {
109
+ this.context = config.context;
110
+ this.initialStateId = config.initialState;
111
+ this.emitter = new StateEventEmitter(config.historySize ?? 100);
112
+ for (const state of config.states) {
113
+ if (this.stateMap.has(state.id)) {
114
+ throw new Error(`Duplicate state id: "${state.id}"`);
115
+ }
116
+ this.stateMap.set(state.id, state);
117
+ }
118
+ if (!this.stateMap.has(config.initialState)) {
119
+ throw new StateNotFoundError(config.initialState);
120
+ }
121
+ }
122
+ get currentStateId() {
123
+ if (!this.currentState) throw new MachineNotStartedError();
124
+ return this.currentState.id;
125
+ }
126
+ get isStarted() {
127
+ return this._isStarted;
128
+ }
129
+ start() {
130
+ if (this._isStarted) return;
131
+ const state = this.stateMap.get(this.initialStateId);
132
+ if (!state) throw new StateNotFoundError(this.initialStateId);
133
+ this.currentState = state;
134
+ this._isStarted = true;
135
+ this.currentState.onEnter(this.context, null);
136
+ }
137
+ stop() {
138
+ if (!this._isStarted || !this.currentState) return;
139
+ this.currentState.onExit(this.context, null);
140
+ this.currentState = null;
141
+ this._isStarted = false;
142
+ }
143
+ transitionTo(stateId) {
144
+ if (!this.currentState) throw new MachineNotStartedError();
145
+ const to = this.stateMap.get(stateId);
146
+ if (!to) throw new StateNotFoundError(stateId);
147
+ const from = this.currentState;
148
+ if (!from.canTransitionTo(stateId, this.context)) {
149
+ throw new TransitionDeniedError(from.id, stateId);
150
+ }
151
+ const change = {
152
+ from: from.id,
153
+ to: to.id,
154
+ timestamp: Date.now()
155
+ };
156
+ from.onExit(this.context, to.id);
157
+ this.currentState = to;
158
+ this.emitter.emit(change);
159
+ to.onEnter(this.context, from.id);
160
+ }
161
+ update(dt) {
162
+ if (!this.currentState) throw new MachineNotStartedError();
163
+ const next = this.currentState.onUpdate(this.context, dt);
164
+ if (next) {
165
+ this.transitionTo(next);
166
+ }
167
+ }
168
+ on(listener) {
169
+ return this.emitter.on(listener);
170
+ }
171
+ getHistory() {
172
+ return this.emitter.getHistory();
173
+ }
174
+ };
175
+
176
+ // src/lib/HierarchicalState.ts
177
+ var HierarchicalState = class extends BaseState {
178
+ childMachine = null;
179
+ onEnter(ctx, _prevState) {
180
+ const config = this.createChildConfig(ctx);
181
+ this.childMachine = new StateMachine(config);
182
+ this.childMachine.start();
183
+ }
184
+ onUpdate(_ctx, _dt) {
185
+ return void 0;
186
+ }
187
+ onExit(_ctx, _nextState) {
188
+ this.childMachine?.stop();
189
+ this.childMachine = null;
190
+ }
191
+ };
192
+ // Annotate the CommonJS export names for ESM import in node:
193
+ 0 && (module.exports = {
194
+ BaseState,
195
+ HierarchicalState,
196
+ MachineNotStartedError,
197
+ StateEventEmitter,
198
+ StateMachine,
199
+ StateNotFoundError,
200
+ TransitionDeniedError
201
+ });
202
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/index.ts","../src/lib/State.ts","../src/lib/StateEvent.ts","../src/lib/errors.ts","../src/lib/StateMachine.ts","../src/lib/HierarchicalState.ts"],"sourcesContent":["export { BaseState } from \"./State.js\";\nexport { StateMachine } from \"./StateMachine.js\";\nexport { StateEventEmitter } from \"./StateEvent.js\";\nexport { HierarchicalState } from \"./HierarchicalState.js\";\nexport {\n StateNotFoundError,\n MachineNotStartedError,\n TransitionDeniedError,\n} from \"./errors.js\";\nexport type {\n IState,\n IStateMachine,\n StateChangeEvent,\n StateMachineConfig,\n} from \"./interfaces.js\";\nexport type { StateEventListener } from \"./StateEvent.js\";\n","import type { IState } from \"./interfaces.js\";\n\n/**\n * Abstract base class providing default no-op lifecycle hooks.\n * Concrete states override only what they need.\n *\n * All hooks are synchronous. Model async work as its own state\n * that polls for completion in onUpdate.\n */\nexport abstract class BaseState<TContext, TStateId extends string>\n implements IState<TContext, TStateId>\n{\n abstract readonly id: TStateId;\n\n canTransitionTo(_targetState: TStateId, _ctx: TContext): boolean {\n return true;\n }\n\n onEnter(_ctx: TContext, _prevState: TStateId | null): void {\n // no-op\n }\n\n onUpdate(_ctx: TContext, _dt: number): TStateId | undefined {\n return undefined;\n }\n\n onExit(_ctx: TContext, _nextState: TStateId | null): void {\n // no-op\n }\n}\n","import type { StateChangeEvent } from \"./interfaces.js\";\n\nexport type StateEventListener<TStateId extends string> = (\n event: StateChangeEvent<TStateId>,\n) => void;\n\n/**\n * Typed event emitter for state changes with bounded history.\n */\nexport class StateEventEmitter<TStateId extends string> {\n private readonly listeners: StateEventListener<TStateId>[] = [];\n private readonly history: StateChangeEvent<TStateId>[] = [];\n private readonly maxHistory: number;\n\n constructor(maxHistory: number = 100) {\n this.maxHistory = maxHistory;\n }\n\n on(listener: StateEventListener<TStateId>): () => void {\n this.listeners.push(listener);\n return () => {\n const idx = this.listeners.indexOf(listener);\n if (idx !== -1) this.listeners.splice(idx, 1);\n };\n }\n\n emit(change: StateChangeEvent<TStateId>): void {\n this.history.push(change);\n if (this.history.length > this.maxHistory) {\n this.history.shift();\n }\n for (const listener of this.listeners) {\n listener(change);\n }\n }\n\n getHistory(): ReadonlyArray<StateChangeEvent<TStateId>> {\n return [...this.history];\n }\n\n clear(): void {\n this.history.length = 0;\n this.listeners.length = 0;\n }\n}\n","export class StateNotFoundError extends Error {\n constructor(stateId: string) {\n super(`State not found: \"${stateId}\"`);\n this.name = \"StateNotFoundError\";\n }\n}\n\nexport class MachineNotStartedError extends Error {\n constructor() {\n super(\"State machine has not been started. Call start() first.\");\n this.name = \"MachineNotStartedError\";\n }\n}\n\nexport class TransitionDeniedError extends Error {\n constructor(from: string, to: string) {\n super(`Transition denied: \"${from}\" -> \"${to}\"`);\n this.name = \"TransitionDeniedError\";\n }\n}\n","import type {\n IState,\n IStateMachine,\n StateMachineConfig,\n StateChangeEvent,\n} from \"./interfaces.js\";\nimport { StateEventEmitter, type StateEventListener } from \"./StateEvent.js\";\nimport {\n StateNotFoundError,\n MachineNotStartedError,\n TransitionDeniedError,\n} from \"./errors.js\";\n\nexport class StateMachine<TContext, TStateId extends string>\n implements IStateMachine<TContext, TStateId>\n{\n private readonly stateMap = new Map<TStateId, IState<TContext, TStateId>>();\n private readonly emitter: StateEventEmitter<TStateId>;\n private currentState: IState<TContext, TStateId> | null = null;\n private readonly initialStateId: TStateId;\n private _isStarted = false;\n\n readonly context: TContext;\n\n constructor(config: StateMachineConfig<TContext, TStateId>) {\n this.context = config.context;\n this.initialStateId = config.initialState;\n this.emitter = new StateEventEmitter(config.historySize ?? 100);\n\n for (const state of config.states) {\n if (this.stateMap.has(state.id)) {\n throw new Error(`Duplicate state id: \"${state.id}\"`);\n }\n this.stateMap.set(state.id, state);\n }\n\n if (!this.stateMap.has(config.initialState)) {\n throw new StateNotFoundError(config.initialState);\n }\n }\n\n get currentStateId(): TStateId {\n if (!this.currentState) throw new MachineNotStartedError();\n return this.currentState.id;\n }\n\n get isStarted(): boolean {\n return this._isStarted;\n }\n\n start(): void {\n if (this._isStarted) return;\n const state = this.stateMap.get(this.initialStateId);\n if (!state) throw new StateNotFoundError(this.initialStateId);\n this.currentState = state;\n this._isStarted = true;\n this.currentState.onEnter(this.context, null);\n }\n\n stop(): void {\n if (!this._isStarted || !this.currentState) return;\n this.currentState.onExit(this.context, null);\n this.currentState = null;\n this._isStarted = false;\n }\n\n transitionTo(stateId: TStateId): void {\n if (!this.currentState) throw new MachineNotStartedError();\n const to = this.stateMap.get(stateId);\n if (!to) throw new StateNotFoundError(stateId);\n\n const from = this.currentState;\n if (!from.canTransitionTo(stateId, this.context)) {\n throw new TransitionDeniedError(from.id, stateId);\n }\n\n const change: StateChangeEvent<TStateId> = {\n from: from.id,\n to: to.id,\n timestamp: Date.now(),\n };\n\n from.onExit(this.context, to.id);\n this.currentState = to;\n this.emitter.emit(change);\n to.onEnter(this.context, from.id);\n }\n\n update(dt: number): void {\n if (!this.currentState) throw new MachineNotStartedError();\n const next = this.currentState.onUpdate(this.context, dt);\n if (next) {\n this.transitionTo(next);\n }\n }\n\n on(listener: StateEventListener<TStateId>): () => void {\n return this.emitter.on(listener);\n }\n\n getHistory(): ReadonlyArray<StateChangeEvent<TStateId>> {\n return this.emitter.getHistory();\n }\n}\n","import { BaseState } from \"./State.js\";\nimport { StateMachine } from \"./StateMachine.js\";\nimport type { StateMachineConfig } from \"./interfaces.js\";\n\n/**\n * A composite state that contains a nested StateMachine.\n * When this state is entered, the nested machine starts.\n * When this state is exited, the nested machine stops.\n * On update, the nested machine is updated.\n */\nexport abstract class HierarchicalState<\n TContext,\n TStateId extends string,\n TChildStateId extends string,\n> extends BaseState<TContext, TStateId> {\n protected childMachine: StateMachine<TContext, TChildStateId> | null = null;\n\n protected abstract createChildConfig(\n ctx: TContext,\n ): StateMachineConfig<TContext, TChildStateId>;\n\n override onEnter(ctx: TContext, _prevState: TStateId | null): void {\n const config = this.createChildConfig(ctx);\n this.childMachine = new StateMachine(config);\n this.childMachine.start();\n }\n\n override onUpdate(_ctx: TContext, _dt: number): TStateId | undefined {\n // Child machine update must be called separately by the consumer\n // or override this method to call this.childMachine.update(dt).\n return undefined;\n }\n\n override onExit(_ctx: TContext, _nextState: TStateId | null): void {\n this.childMachine?.stop();\n this.childMachine = null;\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * A single state in the machine.
3
+ * TContext is shared mutable data, TStateId identifies states.
4
+ *
5
+ * All lifecycle hooks are synchronous. Model async work as its own
6
+ * state that polls for completion in onUpdate.
7
+ */
8
+ interface IState<TContext, TStateId extends string> {
9
+ readonly id: TStateId;
10
+ canTransitionTo(targetState: TStateId, ctx: TContext): boolean;
11
+ onEnter(ctx: TContext, prevState: TStateId | null): void;
12
+ onUpdate(ctx: TContext, dt: number): TStateId | undefined;
13
+ onExit(ctx: TContext, nextState: TStateId | null): void;
14
+ }
15
+ /**
16
+ * Recorded state change for history / debugging.
17
+ */
18
+ interface StateChangeEvent<TStateId extends string> {
19
+ from: TStateId;
20
+ to: TStateId;
21
+ timestamp: number;
22
+ }
23
+ /**
24
+ * Configuration to construct a StateMachine.
25
+ */
26
+ interface StateMachineConfig<TContext, TStateId extends string> {
27
+ states: IState<TContext, TStateId>[];
28
+ initialState: TStateId;
29
+ context: TContext;
30
+ historySize?: number;
31
+ }
32
+ /**
33
+ * Public interface for a state machine.
34
+ */
35
+ interface IStateMachine<TContext, TStateId extends string> {
36
+ readonly currentStateId: TStateId;
37
+ readonly context: TContext;
38
+ readonly isStarted: boolean;
39
+ start(): void;
40
+ stop(): void;
41
+ transitionTo(stateId: TStateId): void;
42
+ update(dt: number): void;
43
+ getHistory(): ReadonlyArray<StateChangeEvent<TStateId>>;
44
+ }
45
+
46
+ /**
47
+ * Abstract base class providing default no-op lifecycle hooks.
48
+ * Concrete states override only what they need.
49
+ *
50
+ * All hooks are synchronous. Model async work as its own state
51
+ * that polls for completion in onUpdate.
52
+ */
53
+ declare abstract class BaseState<TContext, TStateId extends string> implements IState<TContext, TStateId> {
54
+ abstract readonly id: TStateId;
55
+ canTransitionTo(_targetState: TStateId, _ctx: TContext): boolean;
56
+ onEnter(_ctx: TContext, _prevState: TStateId | null): void;
57
+ onUpdate(_ctx: TContext, _dt: number): TStateId | undefined;
58
+ onExit(_ctx: TContext, _nextState: TStateId | null): void;
59
+ }
60
+
61
+ type StateEventListener<TStateId extends string> = (event: StateChangeEvent<TStateId>) => void;
62
+ /**
63
+ * Typed event emitter for state changes with bounded history.
64
+ */
65
+ declare class StateEventEmitter<TStateId extends string> {
66
+ private readonly listeners;
67
+ private readonly history;
68
+ private readonly maxHistory;
69
+ constructor(maxHistory?: number);
70
+ on(listener: StateEventListener<TStateId>): () => void;
71
+ emit(change: StateChangeEvent<TStateId>): void;
72
+ getHistory(): ReadonlyArray<StateChangeEvent<TStateId>>;
73
+ clear(): void;
74
+ }
75
+
76
+ declare class StateMachine<TContext, TStateId extends string> implements IStateMachine<TContext, TStateId> {
77
+ private readonly stateMap;
78
+ private readonly emitter;
79
+ private currentState;
80
+ private readonly initialStateId;
81
+ private _isStarted;
82
+ readonly context: TContext;
83
+ constructor(config: StateMachineConfig<TContext, TStateId>);
84
+ get currentStateId(): TStateId;
85
+ get isStarted(): boolean;
86
+ start(): void;
87
+ stop(): void;
88
+ transitionTo(stateId: TStateId): void;
89
+ update(dt: number): void;
90
+ on(listener: StateEventListener<TStateId>): () => void;
91
+ getHistory(): ReadonlyArray<StateChangeEvent<TStateId>>;
92
+ }
93
+
94
+ /**
95
+ * A composite state that contains a nested StateMachine.
96
+ * When this state is entered, the nested machine starts.
97
+ * When this state is exited, the nested machine stops.
98
+ * On update, the nested machine is updated.
99
+ */
100
+ declare abstract class HierarchicalState<TContext, TStateId extends string, TChildStateId extends string> extends BaseState<TContext, TStateId> {
101
+ protected childMachine: StateMachine<TContext, TChildStateId> | null;
102
+ protected abstract createChildConfig(ctx: TContext): StateMachineConfig<TContext, TChildStateId>;
103
+ onEnter(ctx: TContext, _prevState: TStateId | null): void;
104
+ onUpdate(_ctx: TContext, _dt: number): TStateId | undefined;
105
+ onExit(_ctx: TContext, _nextState: TStateId | null): void;
106
+ }
107
+
108
+ declare class StateNotFoundError extends Error {
109
+ constructor(stateId: string);
110
+ }
111
+ declare class MachineNotStartedError extends Error {
112
+ constructor();
113
+ }
114
+ declare class TransitionDeniedError extends Error {
115
+ constructor(from: string, to: string);
116
+ }
117
+
118
+ export { BaseState, HierarchicalState, type IState, type IStateMachine, MachineNotStartedError, type StateChangeEvent, StateEventEmitter, type StateEventListener, StateMachine, type StateMachineConfig, StateNotFoundError, TransitionDeniedError };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * A single state in the machine.
3
+ * TContext is shared mutable data, TStateId identifies states.
4
+ *
5
+ * All lifecycle hooks are synchronous. Model async work as its own
6
+ * state that polls for completion in onUpdate.
7
+ */
8
+ interface IState<TContext, TStateId extends string> {
9
+ readonly id: TStateId;
10
+ canTransitionTo(targetState: TStateId, ctx: TContext): boolean;
11
+ onEnter(ctx: TContext, prevState: TStateId | null): void;
12
+ onUpdate(ctx: TContext, dt: number): TStateId | undefined;
13
+ onExit(ctx: TContext, nextState: TStateId | null): void;
14
+ }
15
+ /**
16
+ * Recorded state change for history / debugging.
17
+ */
18
+ interface StateChangeEvent<TStateId extends string> {
19
+ from: TStateId;
20
+ to: TStateId;
21
+ timestamp: number;
22
+ }
23
+ /**
24
+ * Configuration to construct a StateMachine.
25
+ */
26
+ interface StateMachineConfig<TContext, TStateId extends string> {
27
+ states: IState<TContext, TStateId>[];
28
+ initialState: TStateId;
29
+ context: TContext;
30
+ historySize?: number;
31
+ }
32
+ /**
33
+ * Public interface for a state machine.
34
+ */
35
+ interface IStateMachine<TContext, TStateId extends string> {
36
+ readonly currentStateId: TStateId;
37
+ readonly context: TContext;
38
+ readonly isStarted: boolean;
39
+ start(): void;
40
+ stop(): void;
41
+ transitionTo(stateId: TStateId): void;
42
+ update(dt: number): void;
43
+ getHistory(): ReadonlyArray<StateChangeEvent<TStateId>>;
44
+ }
45
+
46
+ /**
47
+ * Abstract base class providing default no-op lifecycle hooks.
48
+ * Concrete states override only what they need.
49
+ *
50
+ * All hooks are synchronous. Model async work as its own state
51
+ * that polls for completion in onUpdate.
52
+ */
53
+ declare abstract class BaseState<TContext, TStateId extends string> implements IState<TContext, TStateId> {
54
+ abstract readonly id: TStateId;
55
+ canTransitionTo(_targetState: TStateId, _ctx: TContext): boolean;
56
+ onEnter(_ctx: TContext, _prevState: TStateId | null): void;
57
+ onUpdate(_ctx: TContext, _dt: number): TStateId | undefined;
58
+ onExit(_ctx: TContext, _nextState: TStateId | null): void;
59
+ }
60
+
61
+ type StateEventListener<TStateId extends string> = (event: StateChangeEvent<TStateId>) => void;
62
+ /**
63
+ * Typed event emitter for state changes with bounded history.
64
+ */
65
+ declare class StateEventEmitter<TStateId extends string> {
66
+ private readonly listeners;
67
+ private readonly history;
68
+ private readonly maxHistory;
69
+ constructor(maxHistory?: number);
70
+ on(listener: StateEventListener<TStateId>): () => void;
71
+ emit(change: StateChangeEvent<TStateId>): void;
72
+ getHistory(): ReadonlyArray<StateChangeEvent<TStateId>>;
73
+ clear(): void;
74
+ }
75
+
76
+ declare class StateMachine<TContext, TStateId extends string> implements IStateMachine<TContext, TStateId> {
77
+ private readonly stateMap;
78
+ private readonly emitter;
79
+ private currentState;
80
+ private readonly initialStateId;
81
+ private _isStarted;
82
+ readonly context: TContext;
83
+ constructor(config: StateMachineConfig<TContext, TStateId>);
84
+ get currentStateId(): TStateId;
85
+ get isStarted(): boolean;
86
+ start(): void;
87
+ stop(): void;
88
+ transitionTo(stateId: TStateId): void;
89
+ update(dt: number): void;
90
+ on(listener: StateEventListener<TStateId>): () => void;
91
+ getHistory(): ReadonlyArray<StateChangeEvent<TStateId>>;
92
+ }
93
+
94
+ /**
95
+ * A composite state that contains a nested StateMachine.
96
+ * When this state is entered, the nested machine starts.
97
+ * When this state is exited, the nested machine stops.
98
+ * On update, the nested machine is updated.
99
+ */
100
+ declare abstract class HierarchicalState<TContext, TStateId extends string, TChildStateId extends string> extends BaseState<TContext, TStateId> {
101
+ protected childMachine: StateMachine<TContext, TChildStateId> | null;
102
+ protected abstract createChildConfig(ctx: TContext): StateMachineConfig<TContext, TChildStateId>;
103
+ onEnter(ctx: TContext, _prevState: TStateId | null): void;
104
+ onUpdate(_ctx: TContext, _dt: number): TStateId | undefined;
105
+ onExit(_ctx: TContext, _nextState: TStateId | null): void;
106
+ }
107
+
108
+ declare class StateNotFoundError extends Error {
109
+ constructor(stateId: string);
110
+ }
111
+ declare class MachineNotStartedError extends Error {
112
+ constructor();
113
+ }
114
+ declare class TransitionDeniedError extends Error {
115
+ constructor(from: string, to: string);
116
+ }
117
+
118
+ export { BaseState, HierarchicalState, type IState, type IStateMachine, MachineNotStartedError, type StateChangeEvent, StateEventEmitter, type StateEventListener, StateMachine, type StateMachineConfig, StateNotFoundError, TransitionDeniedError };
package/dist/index.js ADDED
@@ -0,0 +1,169 @@
1
+ // src/lib/State.ts
2
+ var BaseState = class {
3
+ canTransitionTo(_targetState, _ctx) {
4
+ return true;
5
+ }
6
+ onEnter(_ctx, _prevState) {
7
+ }
8
+ onUpdate(_ctx, _dt) {
9
+ return void 0;
10
+ }
11
+ onExit(_ctx, _nextState) {
12
+ }
13
+ };
14
+
15
+ // src/lib/StateEvent.ts
16
+ var StateEventEmitter = class {
17
+ listeners = [];
18
+ history = [];
19
+ maxHistory;
20
+ constructor(maxHistory = 100) {
21
+ this.maxHistory = maxHistory;
22
+ }
23
+ on(listener) {
24
+ this.listeners.push(listener);
25
+ return () => {
26
+ const idx = this.listeners.indexOf(listener);
27
+ if (idx !== -1) this.listeners.splice(idx, 1);
28
+ };
29
+ }
30
+ emit(change) {
31
+ this.history.push(change);
32
+ if (this.history.length > this.maxHistory) {
33
+ this.history.shift();
34
+ }
35
+ for (const listener of this.listeners) {
36
+ listener(change);
37
+ }
38
+ }
39
+ getHistory() {
40
+ return [...this.history];
41
+ }
42
+ clear() {
43
+ this.history.length = 0;
44
+ this.listeners.length = 0;
45
+ }
46
+ };
47
+
48
+ // src/lib/errors.ts
49
+ var StateNotFoundError = class extends Error {
50
+ constructor(stateId) {
51
+ super(`State not found: "${stateId}"`);
52
+ this.name = "StateNotFoundError";
53
+ }
54
+ };
55
+ var MachineNotStartedError = class extends Error {
56
+ constructor() {
57
+ super("State machine has not been started. Call start() first.");
58
+ this.name = "MachineNotStartedError";
59
+ }
60
+ };
61
+ var TransitionDeniedError = class extends Error {
62
+ constructor(from, to) {
63
+ super(`Transition denied: "${from}" -> "${to}"`);
64
+ this.name = "TransitionDeniedError";
65
+ }
66
+ };
67
+
68
+ // src/lib/StateMachine.ts
69
+ var StateMachine = class {
70
+ stateMap = /* @__PURE__ */ new Map();
71
+ emitter;
72
+ currentState = null;
73
+ initialStateId;
74
+ _isStarted = false;
75
+ context;
76
+ constructor(config) {
77
+ this.context = config.context;
78
+ this.initialStateId = config.initialState;
79
+ this.emitter = new StateEventEmitter(config.historySize ?? 100);
80
+ for (const state of config.states) {
81
+ if (this.stateMap.has(state.id)) {
82
+ throw new Error(`Duplicate state id: "${state.id}"`);
83
+ }
84
+ this.stateMap.set(state.id, state);
85
+ }
86
+ if (!this.stateMap.has(config.initialState)) {
87
+ throw new StateNotFoundError(config.initialState);
88
+ }
89
+ }
90
+ get currentStateId() {
91
+ if (!this.currentState) throw new MachineNotStartedError();
92
+ return this.currentState.id;
93
+ }
94
+ get isStarted() {
95
+ return this._isStarted;
96
+ }
97
+ start() {
98
+ if (this._isStarted) return;
99
+ const state = this.stateMap.get(this.initialStateId);
100
+ if (!state) throw new StateNotFoundError(this.initialStateId);
101
+ this.currentState = state;
102
+ this._isStarted = true;
103
+ this.currentState.onEnter(this.context, null);
104
+ }
105
+ stop() {
106
+ if (!this._isStarted || !this.currentState) return;
107
+ this.currentState.onExit(this.context, null);
108
+ this.currentState = null;
109
+ this._isStarted = false;
110
+ }
111
+ transitionTo(stateId) {
112
+ if (!this.currentState) throw new MachineNotStartedError();
113
+ const to = this.stateMap.get(stateId);
114
+ if (!to) throw new StateNotFoundError(stateId);
115
+ const from = this.currentState;
116
+ if (!from.canTransitionTo(stateId, this.context)) {
117
+ throw new TransitionDeniedError(from.id, stateId);
118
+ }
119
+ const change = {
120
+ from: from.id,
121
+ to: to.id,
122
+ timestamp: Date.now()
123
+ };
124
+ from.onExit(this.context, to.id);
125
+ this.currentState = to;
126
+ this.emitter.emit(change);
127
+ to.onEnter(this.context, from.id);
128
+ }
129
+ update(dt) {
130
+ if (!this.currentState) throw new MachineNotStartedError();
131
+ const next = this.currentState.onUpdate(this.context, dt);
132
+ if (next) {
133
+ this.transitionTo(next);
134
+ }
135
+ }
136
+ on(listener) {
137
+ return this.emitter.on(listener);
138
+ }
139
+ getHistory() {
140
+ return this.emitter.getHistory();
141
+ }
142
+ };
143
+
144
+ // src/lib/HierarchicalState.ts
145
+ var HierarchicalState = class extends BaseState {
146
+ childMachine = null;
147
+ onEnter(ctx, _prevState) {
148
+ const config = this.createChildConfig(ctx);
149
+ this.childMachine = new StateMachine(config);
150
+ this.childMachine.start();
151
+ }
152
+ onUpdate(_ctx, _dt) {
153
+ return void 0;
154
+ }
155
+ onExit(_ctx, _nextState) {
156
+ this.childMachine?.stop();
157
+ this.childMachine = null;
158
+ }
159
+ };
160
+ export {
161
+ BaseState,
162
+ HierarchicalState,
163
+ MachineNotStartedError,
164
+ StateEventEmitter,
165
+ StateMachine,
166
+ StateNotFoundError,
167
+ TransitionDeniedError
168
+ };
169
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/State.ts","../src/lib/StateEvent.ts","../src/lib/errors.ts","../src/lib/StateMachine.ts","../src/lib/HierarchicalState.ts"],"sourcesContent":["import type { IState } from \"./interfaces.js\";\n\n/**\n * Abstract base class providing default no-op lifecycle hooks.\n * Concrete states override only what they need.\n *\n * All hooks are synchronous. Model async work as its own state\n * that polls for completion in onUpdate.\n */\nexport abstract class BaseState<TContext, TStateId extends string>\n implements IState<TContext, TStateId>\n{\n abstract readonly id: TStateId;\n\n canTransitionTo(_targetState: TStateId, _ctx: TContext): boolean {\n return true;\n }\n\n onEnter(_ctx: TContext, _prevState: TStateId | null): void {\n // no-op\n }\n\n onUpdate(_ctx: TContext, _dt: number): TStateId | undefined {\n return undefined;\n }\n\n onExit(_ctx: TContext, _nextState: TStateId | null): void {\n // no-op\n }\n}\n","import type { StateChangeEvent } from \"./interfaces.js\";\n\nexport type StateEventListener<TStateId extends string> = (\n event: StateChangeEvent<TStateId>,\n) => void;\n\n/**\n * Typed event emitter for state changes with bounded history.\n */\nexport class StateEventEmitter<TStateId extends string> {\n private readonly listeners: StateEventListener<TStateId>[] = [];\n private readonly history: StateChangeEvent<TStateId>[] = [];\n private readonly maxHistory: number;\n\n constructor(maxHistory: number = 100) {\n this.maxHistory = maxHistory;\n }\n\n on(listener: StateEventListener<TStateId>): () => void {\n this.listeners.push(listener);\n return () => {\n const idx = this.listeners.indexOf(listener);\n if (idx !== -1) this.listeners.splice(idx, 1);\n };\n }\n\n emit(change: StateChangeEvent<TStateId>): void {\n this.history.push(change);\n if (this.history.length > this.maxHistory) {\n this.history.shift();\n }\n for (const listener of this.listeners) {\n listener(change);\n }\n }\n\n getHistory(): ReadonlyArray<StateChangeEvent<TStateId>> {\n return [...this.history];\n }\n\n clear(): void {\n this.history.length = 0;\n this.listeners.length = 0;\n }\n}\n","export class StateNotFoundError extends Error {\n constructor(stateId: string) {\n super(`State not found: \"${stateId}\"`);\n this.name = \"StateNotFoundError\";\n }\n}\n\nexport class MachineNotStartedError extends Error {\n constructor() {\n super(\"State machine has not been started. Call start() first.\");\n this.name = \"MachineNotStartedError\";\n }\n}\n\nexport class TransitionDeniedError extends Error {\n constructor(from: string, to: string) {\n super(`Transition denied: \"${from}\" -> \"${to}\"`);\n this.name = \"TransitionDeniedError\";\n }\n}\n","import type {\n IState,\n IStateMachine,\n StateMachineConfig,\n StateChangeEvent,\n} from \"./interfaces.js\";\nimport { StateEventEmitter, type StateEventListener } from \"./StateEvent.js\";\nimport {\n StateNotFoundError,\n MachineNotStartedError,\n TransitionDeniedError,\n} from \"./errors.js\";\n\nexport class StateMachine<TContext, TStateId extends string>\n implements IStateMachine<TContext, TStateId>\n{\n private readonly stateMap = new Map<TStateId, IState<TContext, TStateId>>();\n private readonly emitter: StateEventEmitter<TStateId>;\n private currentState: IState<TContext, TStateId> | null = null;\n private readonly initialStateId: TStateId;\n private _isStarted = false;\n\n readonly context: TContext;\n\n constructor(config: StateMachineConfig<TContext, TStateId>) {\n this.context = config.context;\n this.initialStateId = config.initialState;\n this.emitter = new StateEventEmitter(config.historySize ?? 100);\n\n for (const state of config.states) {\n if (this.stateMap.has(state.id)) {\n throw new Error(`Duplicate state id: \"${state.id}\"`);\n }\n this.stateMap.set(state.id, state);\n }\n\n if (!this.stateMap.has(config.initialState)) {\n throw new StateNotFoundError(config.initialState);\n }\n }\n\n get currentStateId(): TStateId {\n if (!this.currentState) throw new MachineNotStartedError();\n return this.currentState.id;\n }\n\n get isStarted(): boolean {\n return this._isStarted;\n }\n\n start(): void {\n if (this._isStarted) return;\n const state = this.stateMap.get(this.initialStateId);\n if (!state) throw new StateNotFoundError(this.initialStateId);\n this.currentState = state;\n this._isStarted = true;\n this.currentState.onEnter(this.context, null);\n }\n\n stop(): void {\n if (!this._isStarted || !this.currentState) return;\n this.currentState.onExit(this.context, null);\n this.currentState = null;\n this._isStarted = false;\n }\n\n transitionTo(stateId: TStateId): void {\n if (!this.currentState) throw new MachineNotStartedError();\n const to = this.stateMap.get(stateId);\n if (!to) throw new StateNotFoundError(stateId);\n\n const from = this.currentState;\n if (!from.canTransitionTo(stateId, this.context)) {\n throw new TransitionDeniedError(from.id, stateId);\n }\n\n const change: StateChangeEvent<TStateId> = {\n from: from.id,\n to: to.id,\n timestamp: Date.now(),\n };\n\n from.onExit(this.context, to.id);\n this.currentState = to;\n this.emitter.emit(change);\n to.onEnter(this.context, from.id);\n }\n\n update(dt: number): void {\n if (!this.currentState) throw new MachineNotStartedError();\n const next = this.currentState.onUpdate(this.context, dt);\n if (next) {\n this.transitionTo(next);\n }\n }\n\n on(listener: StateEventListener<TStateId>): () => void {\n return this.emitter.on(listener);\n }\n\n getHistory(): ReadonlyArray<StateChangeEvent<TStateId>> {\n return this.emitter.getHistory();\n }\n}\n","import { BaseState } from \"./State.js\";\nimport { StateMachine } from \"./StateMachine.js\";\nimport type { StateMachineConfig } from \"./interfaces.js\";\n\n/**\n * A composite state that contains a nested StateMachine.\n * When this state is entered, the nested machine starts.\n * When this state is exited, the nested machine stops.\n * On update, the nested machine is updated.\n */\nexport abstract class HierarchicalState<\n TContext,\n TStateId extends string,\n TChildStateId extends string,\n> extends BaseState<TContext, TStateId> {\n protected childMachine: StateMachine<TContext, TChildStateId> | null = null;\n\n protected abstract createChildConfig(\n ctx: TContext,\n ): StateMachineConfig<TContext, TChildStateId>;\n\n override onEnter(ctx: TContext, _prevState: TStateId | null): void {\n const config = this.createChildConfig(ctx);\n this.childMachine = new StateMachine(config);\n this.childMachine.start();\n }\n\n override onUpdate(_ctx: TContext, _dt: number): TStateId | undefined {\n // Child machine update must be called separately by the consumer\n // or override this method to call this.childMachine.update(dt).\n return undefined;\n }\n\n override onExit(_ctx: TContext, _nextState: TStateId | null): void {\n this.childMachine?.stop();\n this.childMachine = null;\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@duyquangnvx/state-machine",
3
+ "version": "0.1.0",
4
+ "description": "Generic, type-safe finite state machine library for TypeScript",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "import": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "require": {
13
+ "types": "./dist/index.d.cts",
14
+ "default": "./dist/index.cjs"
15
+ }
16
+ }
17
+ },
18
+ "main": "./dist/index.cjs",
19
+ "module": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "files": ["dist"],
22
+ "publishConfig": { "access": "public" },
23
+ "license": "ISC",
24
+ "keywords": ["state-machine", "fsm", "typescript", "game", "state"],
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "build:demo": "tsc",
28
+ "demo": "npm run build:demo && node dist/demo/main.js",
29
+ "demo:slot": "npm run build:demo && node dist/demo/slot-main.js",
30
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
31
+ "clean": "rimraf dist",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "devDependencies": {
35
+ "@types/jest": "^29.5.0",
36
+ "@types/node": "^22.0.0",
37
+ "jest": "^29.7.0",
38
+ "ts-jest": "^29.2.0",
39
+ "tsup": "^8.5.1",
40
+ "typescript": "^5.7.0"
41
+ }
42
+ }