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 +53 -46
- package/lib/index.d.ts +13 -17
- package/lib/index.js +6 -7
- package/package.json +1 -1
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
|
|
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,
|
|
23
|
-
([action,
|
|
23
|
+
// each rule receives [state, action, context] and returns [state, action, context]
|
|
24
|
+
([state, action, context]) => {
|
|
24
25
|
if (action.type === 'INCREMENT') {
|
|
25
|
-
return [
|
|
26
|
+
return [{ count: state.count + action.by }, action, context]
|
|
26
27
|
}
|
|
27
|
-
return [action,
|
|
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,
|
|
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 `[
|
|
46
|
+
A **rule** is a function over a `[state, action, context]` tuple:
|
|
46
47
|
|
|
47
48
|
```ts
|
|
48
|
-
type RuleIO<State,
|
|
49
|
-
type Rule<State,
|
|
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
|
-
`
|
|
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 `[
|
|
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
|
-
[
|
|
58
|
-
|
|
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
|
|
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,
|
|
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`
|
|
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 =
|
|
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 `(
|
|
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
|
|
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', (
|
|
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
|
|
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', (
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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`).
|
|
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,
|
|
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 `(
|
|
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 `
|
|
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():
|
|
186
|
-
| `move(action:
|
|
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
|
|
194
|
+
### `GameAction`
|
|
190
195
|
|
|
191
|
-
|
|
196
|
+
The base constraint every action satisfies — an object with a required `type` string:
|
|
192
197
|
|
|
193
198
|
```ts
|
|
194
|
-
type GameAction
|
|
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
|
-
|
|
1
|
+
type GameAction = {
|
|
2
2
|
type: string;
|
|
3
3
|
};
|
|
4
|
-
|
|
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():
|
|
10
|
-
move(action:
|
|
6
|
+
getState(): State;
|
|
7
|
+
move(action: Action): void;
|
|
11
8
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export declare function createRuleFactory<State, Action extends {
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
}
|
|
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 ([
|
|
7
|
-
const nextContext = (context ?? {});
|
|
6
|
+
return ([state, action, context]) => {
|
|
8
7
|
if (type !== undefined && action.type !== type) {
|
|
9
|
-
return [
|
|
8
|
+
return [state, action, context];
|
|
10
9
|
}
|
|
11
10
|
const nextState = produce(state, (draft) => {
|
|
12
|
-
recipe(
|
|
11
|
+
recipe(draft, action, context);
|
|
13
12
|
});
|
|
14
|
-
return [
|
|
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)([
|
|
34
|
-
state = result[
|
|
32
|
+
const result = pipe(...(rules))([state, action, {}]);
|
|
33
|
+
state = result[0];
|
|
35
34
|
for (const listener of listeners) {
|
|
36
35
|
listener();
|
|
37
36
|
}
|