move-by-move 3.1.0 → 4.0.1

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A simple, dependency-free game engine for turn-based games.
4
4
 
5
- `move-by-move` gives you a tiny state container in the spirit of Redux, but built around **rules** instead of reducers. Every move runs through a pipeline of rules, each of which reads the action and the current state and produces the next state. This makes it natural to express turn-based game logic — validation, the move itself, win checks, turn switching — as a sequence of small, composable steps.
5
+ `move-by-move` gives you a tiny state container in the spirit of Redux, but built around **rules** instead of reducers. Every move runs through a pipeline of rules, each of which reads the current state and the action and produces the next state. This makes it natural to express turn-based game logic — validation, the move itself, win checks, turn switching — as a sequence of small, composable steps.
6
6
 
7
7
  ## Installation
8
8
 
@@ -16,15 +16,16 @@ npm install move-by-move
16
16
  import createGame from 'move-by-move'
17
17
 
18
18
  type State = { count: number }
19
+ type Action = { type: 'INCREMENT'; by: number }
19
20
 
20
- const game = createGame<State>(
21
+ const game = createGame<State, Action>(
21
22
  [
22
- // each rule receives [action, state] and returns [action, state]
23
- ([action, state]) => {
23
+ // each rule receives [state, action, context] and returns [state, action, context]
24
+ ([state, action, context]) => {
24
25
  if (action.type === 'INCREMENT') {
25
- return [action, { count: state.count + action.by }]
26
+ return [{ count: state.count + action.by }, action, context]
26
27
  }
27
- return [action, state]
28
+ return [state, action, context]
28
29
  },
29
30
  ],
30
31
  { count: 0 }, // initial state
@@ -38,29 +39,29 @@ game.move({ type: 'INCREMENT', by: 5 }) // logs: state: { count: 6 }
38
39
  unsubscribe() // stop listening
39
40
  ```
40
41
 
41
- > The Quick start writes rules by hand to show the raw `[action, state]` contract. In a real game you'll usually reach for **`createRule`**, which lets you mutate state directly (via [immer](https://immerjs.github.io/immer/)) and run a rule only for a chosen action type — see [below](#authoring-rules-with-createrule). (A third tuple slot, `context`, is shared by *every* rule — raw or `createRule` — for [passing information downstream](#context-sharing-data-between-rules); this example just doesn't need it.)
42
+ > The Quick start writes rules by hand to show the raw `[state, action, context]` contract. In a real game you'll usually reach for **`createRule`**, which lets you mutate state directly (via [immer](https://immerjs.github.io/immer/)) and run a rule only for a chosen action type — see [below](#authoring-rules-with-createrule). The third tuple slot, `context`, is a scratch bag shared by *every* rule — raw or `createRule` — for [passing information downstream](#context-sharing-data-between-rules); this example just threads it through untouched.
42
43
 
43
44
  ## Core concept: rules
44
45
 
45
- A **rule** is a function over a `[action, state, context]` tuple:
46
+ A **rule** is a function over a `[state, action, context]` tuple:
46
47
 
47
48
  ```ts
48
- type RuleIO<State, Context = any> = [action: Immutable<GameAction>, state: Immutable<State>, context?: Context]
49
- type Rule<State, Context = any> = (input: RuleIO<State, Context>) => RuleIO<State, Context>
49
+ type RuleIO<State, Action extends GameAction> = [state: State, action: Action, context: Record<string, unknown>]
50
+ type Rule<State, Action extends GameAction> = (input: RuleIO<Immutable<State>, Immutable<Action>>) => RuleIO<State, Action>
50
51
  ```
51
52
 
52
- `action` and `state` are **deeply read-only** (`Immutable<…>`) for *every* rule — not just `createRule` ones. You read them freely, but mutating either is a compile error. To evolve state, return a new one (or mutate the draft inside `createRule`); to pass information downstream, write to `context`. This is a compile-time guard only, so it adds zero runtime cost.
53
+ The `state` and `action` a rule **receives** are deeply read-only (`Immutable<…>`) for *every* rule — not just `createRule` ones. You read them freely, but mutating either is a compile error. To evolve state, return a new one (or mutate the draft inside `createRule`); to pass information downstream, write to `context`. This is a compile-time guard only, so it adds zero runtime cost.
53
54
 
54
- When you call `game.move(action)`, the engine pipes `[action, state, context]` through every rule **in order** and uses the resulting state as the new state:
55
+ When you call `game.move(action)`, the engine pipes `[state, action, context]` through every rule **in order** and uses the resulting state as the new state:
55
56
 
56
57
  ```
57
- [action, state, context] → rule1 → rule2 → rule3 → [action, newState, context]
58
- └── becomes the new state
58
+ [state, action, context] → rule1 → rule2 → rule3 → [newState, action, context]
59
+ └── becomes the new state
59
60
  ```
60
61
 
61
62
  (`context` starts as a fresh, empty object on every move and is discarded once the move finishes — see [Context](#context-sharing-data-between-rules).)
62
63
 
63
- Because each rule receives the action, the (possibly already-transformed) state, and a shared context, rules can:
64
+ Because each rule receives the (possibly already-transformed) state, the action, and a shared context, rules can:
64
65
 
65
66
  - **transform state** based on the action,
66
67
  - **ignore** actions they don't care about (just return the input unchanged),
@@ -71,9 +72,9 @@ Order matters — a validation rule should run before the rule that applies the
71
72
 
72
73
  ### Authoring rules with `createRule`
73
74
 
74
- Writing `[action, state]` tuples by hand gets verbose, and hand-rolled immutable updates (`{ ...state, board }`) are easy to get wrong. `createRule` lets you **mutate a draft** of the state with [immer](https://immerjs.github.io/immer/) — the engine turns your mutations into a new immutable state for you.
75
+ Writing `[state, action, context]` tuples by hand gets verbose, and hand-rolled immutable updates (`{ ...state, board }`) are easy to get wrong. `createRule` lets you **mutate a draft** of the state with [immer](https://immerjs.github.io/immer/) — the engine turns your mutations into a new immutable state for you.
75
76
 
76
- `move-by-move` stays dependency-free and doesn't ship immer, so you inject your own `produce` once — binding your game's `State` (and, optionally, your `Action` union and `Context` shape) at the same time:
77
+ `move-by-move` stays dependency-free and doesn't ship immer, so you inject your own `produce` once — binding your game's `State` and `Action` union at the same time:
77
78
 
78
79
  ```ts
79
80
  import createGame, { createRuleFactory } from 'move-by-move'
@@ -85,39 +86,47 @@ type State = {
85
86
  winner: string | null
86
87
  }
87
88
 
88
- type Action = { type: 'PLACE'; index: number }
89
+ type Action =
90
+ | { type: 'PLACE'; index: number }
91
+ | { type: 'GIVE_UP' }
89
92
 
90
93
  const createRule = createRuleFactory<State, Action>(produce)
91
94
  ```
92
95
 
93
- A recipe receives `(action, state, context)`:
96
+ A recipe receives `(state, action, context)`:
94
97
 
95
- - `action` — the action being processed, **read-only**.
96
98
  - `state` — a **mutable draft**; mutate it directly, no spreading.
99
+ - `action` — the action being processed, **read-only**.
97
100
  - `context` — a mutable bag **shared by every rule in this move** (see below).
98
101
 
99
- Pass an action **type** as the first argument to run the rule only for that action — `action` is then narrowed to the matching member of your `Action` union (and unknown types are a compile error). Omit it to run on every action. Here's tic-tac-toe:
102
+ Pass an action **type** as the first argument to run the rule only for that action — `action` is then narrowed to the matching member of your `Action` union, so its payload is fully typed (and passing a type your union doesn't include is a compile error). Omit it to run on every action, where `action` is the full union. Here's tic-tac-toe:
100
103
 
101
104
  ```ts
102
- const placeMark = createRule('PLACE', (action, state, context) => {
105
+ const placeMark = createRule('PLACE', (state, action, context) => {
103
106
  if (state.winner) return
104
107
  if (state.board[action.index] !== null) return // square already taken
105
- state.board[action.index] = state.turn // action.index is typed: number
108
+ state.board[action.index] = state.turn // narrowed to PLACE, so action.index is typed
106
109
  context.placed = true // tell downstream rules the move was accepted
107
110
  })
108
111
 
109
- const switchTurn = createRule('PLACE', (action, state, context) => {
112
+ const switchTurn = createRule('PLACE', (state, action, context) => {
110
113
  if (!context.placed) return // only switch when placeMark accepted the move
111
114
  state.turn = state.turn === 'X' ? 'O' : 'X'
112
115
  })
113
116
 
114
- const game = createGame<State>(
115
- [placeMark, switchTurn],
117
+ // Handles the other action; the current player resigns and their opponent wins.
118
+ const giveUp = createRule('GIVE_UP', (state) => {
119
+ if (state.winner) return
120
+ state.winner = state.turn === 'X' ? 'O' : 'X'
121
+ })
122
+
123
+ const game = createGame<State, Action>(
124
+ [placeMark, switchTurn, giveUp],
116
125
  { board: Array(9).fill(null), turn: 'X', winner: null },
117
126
  )
118
127
  ```
119
128
 
120
- For brevity this omits win detection: a complete game adds a rule after `placeMark` that checks the board and sets `state.winner` — which is what `placeMark`'s early `return` guards against.
129
+ For brevity `placeMark` omits win detection: a complete game adds a rule after it that checks the board and sets `state.winner` — which is what `placeMark`'s early `return` guards against.
121
130
 
122
131
  ## Context: sharing data between rules
123
132
 
@@ -134,23 +143,20 @@ So `context` is a separate, mutable scratch space with a clear lifetime:
134
143
 
135
144
  - It's created **fresh (`{}`) at the start of every `move()`**, threaded through all rules, and **discarded** when the move ends. It never persists across moves.
136
145
  - It's a plain object — read it, write it, mutate it freely. (Unlike `state`, it is *not* an immer draft.)
137
- - Type it via the third generic of `createRuleFactory` when you want safety every rule then shares that shape:
138
-
139
- ```ts
140
- const createRule = createRuleFactory<State, Action, { placed?: boolean }>(produce)
141
- // inside any rule, context.placed is now typed as boolean | undefined
142
- ```
146
+ - It's typed as `Record<string, unknown>`: writing any value is fine, and reads come back as `unknown`, so narrow or cast when you need a specific type. (Truthiness checks like `if (context.placed)` work directly.)
143
147
 
144
148
  ## API
145
149
 
146
- ### `createGame<State>(rules, initialState): Game<State>`
150
+ ### `createGame<State, Action>(rules, initialState): Game<State, Action>`
147
151
 
148
152
  Creates a new game instance.
149
153
 
154
+ - `State` — your game state.
155
+ - `Action` — your action union (a discriminated union on `type`); this types `move()` and the action each rule sees.
150
156
  - `rules` — an array of rule functions, applied in order on every move.
151
157
  - `initialState` — the starting state.
152
158
 
153
- ### `createRuleFactory<State, Action, Context>(produce)`
159
+ ### `createRuleFactory<State, Action>(produce)`
154
160
 
155
161
  Binds immer's `produce` (which you supply — `move-by-move` is dependency-free) plus your game's types, and returns a `createRule` helper:
156
162
 
@@ -162,38 +168,39 @@ const createRule = createRuleFactory<State, Action>(produce)
162
168
  ```
163
169
 
164
170
  - `State` — your game state.
165
- - `Action` — your action union (a discriminated union on `type`). Defaults to `GameAction`; supplying it enables payload narrowing and rejects unknown action types.
166
- - `Context` — the shape of the per-move shared bag (see [Context](#context-sharing-data-between-rules)). Defaults to `Record<string, any>`.
171
+ - `Action` — your action union (a discriminated union on `type`). Enables payload narrowing on `action` and rejects unknown types passed to the `createRule(type, …)` form.
167
172
 
168
- `createRule` has two forms, both returning a `Rule<State, Context>`:
173
+ `createRule` has two forms, both returning a `Rule<State, Action>`:
169
174
 
170
175
  | Call | Runs | `action` type |
171
176
  | --- | --- | --- |
172
177
  | `createRule(recipe)` | on every action | the full `Action` union |
173
178
  | `createRule(type, recipe)` | only when `action.type === type` | narrowed to that member |
174
179
 
175
- A recipe is `(action, state, context) => void`: `action` is read-only, `state` is a mutable immer draft, and your mutations are turned into the next immutable state. To pass information to later rules, write to `context` (see [Context](#context-sharing-data-between-rules)).
180
+ A recipe is `(state, action, context) => void`: `state` is a mutable immer draft (your mutations become the next immutable state), `action` is the read-only action (narrowed to the matching member in the `createRule(type, …)` form), and `context` is the per-move shared bag. To pass information to later rules, write to `context` (see [Context](#context-sharing-data-between-rules)).
176
181
 
177
182
  ### `Immutable<T>`
178
183
 
179
- A recursive `readonly` helper used to type the `action` and `state` seen by rules (and the return of `getState`). It's a **compile-time guard only** — nothing is frozen at runtime, so there's no performance cost. Reading is unrestricted; mutating is a compile error, which keeps state transitions explicit (return a new state, or mutate a `createRule` draft) and steers rule-to-rule communication through `context`.
184
+ A recursive `readonly` helper used to type the `state` and `action` a rule receives (and the read-only `action` a `createRule` recipe sees). It's a **compile-time guard only** — nothing is frozen at runtime, so there's no performance cost. Reading is unrestricted; mutating is a compile error, which keeps state transitions explicit (return a new state, or mutate a `createRule` draft) and steers rule-to-rule communication through `context`.
180
185
 
181
- ### `Game<State>`
186
+ ### `Game<State, Action>`
182
187
 
183
188
  | Method | Description |
184
189
  | --- | --- |
185
- | `getState(): Immutable<State>` | Returns the current state, deeply read-only. |
186
- | `move(action: GameAction): void` | Runs `action` through the rule pipeline, updates state, and notifies subscribers. |
190
+ | `getState(): State` | Returns the current state. |
191
+ | `move(action: Action): void` | Runs `action` through the rule pipeline, updates state, and notifies subscribers. |
187
192
  | `subscribe(listener: Function): () => void` | Registers a listener called (with no arguments) after every move. Use `getState()` inside it to read the new state. Returns an unsubscribe function — call it to remove the listener. |
188
193
 
189
- ### `GameAction<Payload>`
194
+ ### `GameAction`
190
195
 
191
- An action is any payload object with a required `type` string:
196
+ The base constraint every action satisfies an object with a required `type` string:
192
197
 
193
198
  ```ts
194
- type GameAction<Payload = any> = Payload & { type: string }
199
+ type GameAction = { type: string }
195
200
  ```
196
201
 
202
+ Your own `Action` union must extend it (each member carrying a `type`). It's the upper bound on the `Action` generic throughout the API.
203
+
197
204
  ## License
198
205
 
199
206
  MIT © Andreas Riedmüller
package/lib/index.d.ts CHANGED
@@ -1,23 +1,19 @@
1
- export type GameAction<Payload = any> = Payload & {
1
+ type GameAction = {
2
2
  type: string;
3
3
  };
4
- export type Immutable<T> = 0 extends 1 & T ? T : T extends readonly (infer E)[] ? readonly Immutable<E>[] : T extends object ? {
5
- readonly [K in keyof T]: Immutable<T[K]>;
6
- } : T;
7
- export interface Game<State> {
4
+ interface Game<State, Action extends GameAction> {
8
5
  subscribe(listener: Function): () => void;
9
- getState(): Immutable<State>;
10
- move(action: GameAction): void;
6
+ getState(): State;
7
+ move(action: Action): void;
11
8
  }
12
- export type RuleIO<State, Context = any> = [action: Immutable<GameAction>, state: Immutable<State>, context?: Context];
13
- export type Rule<State, Context = any> = (input: RuleIO<State, Context>) => RuleIO<State, Context>;
14
- export type Produce = <S>(base: S, recipe: (draft: S) => void) => S;
15
- export declare function createRuleFactory<State, Action extends {
16
- type: string;
17
- } = GameAction, Context extends object = Record<string, any>>(produce: Produce): {
18
- (recipe: (action: Immutable<Action>, state: State, context: Context) => void): Rule<State, Context>;
19
- <Type extends Action["type"]>(type: Type, recipe: (action: Immutable<Extract<Action, {
9
+ type RuleIO<State, Action extends GameAction> = [state: State, action: Action, context: Record<string, unknown>];
10
+ type Rule<State, Action extends GameAction> = (input: RuleIO<State, Action>) => RuleIO<State, Action>;
11
+ type Produce = <S>(base: S, recipe: (draft: S) => void) => S;
12
+ export declare function createRuleFactory<State, Action extends GameAction>(produce: Produce): {
13
+ (recipe: (state: State, action: Action, context: Record<string, unknown>) => void): Rule<State, Action>;
14
+ <Type extends Action["type"]>(type: Type, recipe: (state: State, action: Extract<Action, {
20
15
  type: Type;
21
- }>>, state: State, context: Context) => void): Rule<State, Context>;
16
+ }>, context: Record<string, unknown>) => void): Rule<State, Action>;
22
17
  };
23
- export default function createGame<State>(rules: Array<Rule<State>>, initialGameState: State): Game<State>;
18
+ export default function createGame<State, Action extends GameAction>(rules: Array<Rule<State, Action>>, initialGameState: State): Game<State, Action>;
19
+ export {};
package/lib/index.js CHANGED
@@ -3,15 +3,14 @@ export function createRuleFactory(produce) {
3
3
  function createRule(typeOrRecipe, maybeRecipe) {
4
4
  const type = typeof typeOrRecipe === 'string' ? typeOrRecipe : undefined;
5
5
  const recipe = typeof typeOrRecipe === 'string' ? maybeRecipe : typeOrRecipe;
6
- return ([action, state, context]) => {
7
- const nextContext = (context ?? {});
6
+ return ([state, action, context]) => {
8
7
  if (type !== undefined && action.type !== type) {
9
- return [action, state, nextContext];
8
+ return [state, action, context];
10
9
  }
11
10
  const nextState = produce(state, (draft) => {
12
- recipe(action, draft, nextContext);
11
+ recipe(draft, action, context);
13
12
  });
14
- return [action, nextState, nextContext];
13
+ return [nextState, action, context];
15
14
  };
16
15
  }
17
16
  return createRule;
@@ -30,8 +29,8 @@ export default function createGame(rules, initialGameState) {
30
29
  return state;
31
30
  };
32
31
  const move = (action) => {
33
- const result = pipe(...rules)([action, state, {}]);
34
- state = result[1];
32
+ const result = pipe(...(rules))([state, action, {}]);
33
+ state = result[0];
35
34
  for (const listener of listeners) {
36
35
  listener();
37
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "move-by-move",
3
- "version": "3.1.0",
3
+ "version": "4.0.1",
4
4
  "description": "Simple game engine for turn based games",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",