move-by-move 3.0.0 → 4.0.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 +118 -35
- package/lib/index.d.ts +17 -6
- package/lib/index.js +18 -2
- package/package.json +6 -4
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
|
|
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,35 +39,46 @@ game.move({ type: 'INCREMENT', by: 5 }) // logs: state: { count: 6 }
|
|
|
38
39
|
unsubscribe() // stop listening
|
|
39
40
|
```
|
|
40
41
|
|
|
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.
|
|
43
|
+
|
|
41
44
|
## Core concept: rules
|
|
42
45
|
|
|
43
|
-
A **rule** is a function over a `[action,
|
|
46
|
+
A **rule** is a function over a `[state, action, context]` tuple:
|
|
44
47
|
|
|
45
48
|
```ts
|
|
46
|
-
type RuleIO<State> = [action:
|
|
47
|
-
type Rule<State> = (input: RuleIO<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>
|
|
48
51
|
```
|
|
49
52
|
|
|
50
|
-
|
|
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.
|
|
54
|
+
|
|
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:
|
|
51
56
|
|
|
52
57
|
```
|
|
53
|
-
[action,
|
|
54
|
-
|
|
58
|
+
[state, action, context] → rule1 → rule2 → rule3 → [newState, action, context]
|
|
59
|
+
└── becomes the new state
|
|
55
60
|
```
|
|
56
61
|
|
|
57
|
-
|
|
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).)
|
|
63
|
+
|
|
64
|
+
Because each rule receives the (possibly already-transformed) state, the action, and a shared context, rules can:
|
|
58
65
|
|
|
59
66
|
- **transform state** based on the action,
|
|
60
67
|
- **ignore** actions they don't care about (just return the input unchanged),
|
|
61
|
-
- **
|
|
68
|
+
- **share information** with later rules through the `context` bag,
|
|
62
69
|
- **run unconditionally**, e.g. a rule that switches the active player or checks for a winner after every move.
|
|
63
70
|
|
|
64
71
|
Order matters — a validation rule should run before the rule that applies the move, and a "check for win" rule should run after it.
|
|
65
72
|
|
|
66
|
-
###
|
|
73
|
+
### Authoring rules with `createRule`
|
|
74
|
+
|
|
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.
|
|
76
|
+
|
|
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:
|
|
67
78
|
|
|
68
79
|
```ts
|
|
69
|
-
import createGame from 'move-by-move'
|
|
80
|
+
import createGame, { createRuleFactory } from 'move-by-move'
|
|
81
|
+
import { produce } from 'immer'
|
|
70
82
|
|
|
71
83
|
type State = {
|
|
72
84
|
board: (string | null)[]
|
|
@@ -74,50 +86,121 @@ type State = {
|
|
|
74
86
|
winner: string | null
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const board = [...state.board]
|
|
81
|
-
board[action.index] = state.turn
|
|
82
|
-
return [action, { ...state, board }]
|
|
83
|
-
}
|
|
89
|
+
type Action =
|
|
90
|
+
| { type: 'PLACE'; index: number }
|
|
91
|
+
| { type: 'GIVE_UP' }
|
|
84
92
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
return [action, { ...state, turn: state.turn === 'X' ? 'O' : 'X' }]
|
|
88
|
-
}
|
|
93
|
+
const createRule = createRuleFactory<State, Action>(produce)
|
|
94
|
+
```
|
|
89
95
|
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
A recipe receives `(state, action, context)`:
|
|
97
|
+
|
|
98
|
+
- `state` — a **mutable draft**; mutate it directly, no spreading.
|
|
99
|
+
- `action` — the action being processed, **read-only**.
|
|
100
|
+
- `context` — a mutable bag **shared by every rule in this move** (see below).
|
|
101
|
+
|
|
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:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const placeMark = createRule('PLACE', (state, action, context) => {
|
|
106
|
+
if (state.winner) return
|
|
107
|
+
if (state.board[action.index] !== null) return // square already taken
|
|
108
|
+
state.board[action.index] = state.turn // narrowed to PLACE, so action.index is typed
|
|
109
|
+
context.placed = true // tell downstream rules the move was accepted
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const switchTurn = createRule('PLACE', (state, action, context) => {
|
|
113
|
+
if (!context.placed) return // only switch when placeMark accepted the move
|
|
114
|
+
state.turn = state.turn === 'X' ? 'O' : 'X'
|
|
115
|
+
})
|
|
116
|
+
|
|
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],
|
|
92
125
|
{ board: Array(9).fill(null), turn: 'X', winner: null },
|
|
93
126
|
)
|
|
94
127
|
```
|
|
95
128
|
|
|
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.
|
|
130
|
+
|
|
131
|
+
## Context: sharing data between rules
|
|
132
|
+
|
|
133
|
+
Rules run one after another, and a later rule often needs to know what an earlier one decided. Above, both rules already fire only for `PLACE` actions, but `placeMark` does extra validation (no winner yet, square empty) before making the move. `switchTurn` should only swap the active player **when a mark was actually placed** — otherwise an invalid `PLACE` would still pass the turn.
|
|
134
|
+
|
|
135
|
+
Without a shared channel, `switchTurn` would have to *repeat* every check `placeMark` already did — duplicated logic that drifts out of sync the moment a rule changes. `context` solves this: `placeMark` records its decision with `context.placed = true`, and `switchTurn` just reads it.
|
|
136
|
+
|
|
137
|
+
**Why not put this on `state` or the `action`?**
|
|
138
|
+
|
|
139
|
+
- `state` is your persistent game state — you don't want transient bookkeeping like "was this move valid?" leaking into it (and into your subscribers).
|
|
140
|
+
- the `action` is read-only, by design.
|
|
141
|
+
|
|
142
|
+
So `context` is a separate, mutable scratch space with a clear lifetime:
|
|
143
|
+
|
|
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.
|
|
145
|
+
- It's a plain object — read it, write it, mutate it freely. (Unlike `state`, it is *not* an immer draft.)
|
|
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.)
|
|
147
|
+
|
|
96
148
|
## API
|
|
97
149
|
|
|
98
|
-
### `createGame<State>(rules, initialState): Game<State>`
|
|
150
|
+
### `createGame<State, Action>(rules, initialState): Game<State, Action>`
|
|
99
151
|
|
|
100
152
|
Creates a new game instance.
|
|
101
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.
|
|
102
156
|
- `rules` — an array of rule functions, applied in order on every move.
|
|
103
157
|
- `initialState` — the starting state.
|
|
104
158
|
|
|
105
|
-
### `
|
|
159
|
+
### `createRuleFactory<State, Action>(produce)`
|
|
160
|
+
|
|
161
|
+
Binds immer's `produce` (which you supply — `move-by-move` is dependency-free) plus your game's types, and returns a `createRule` helper:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { produce } from 'immer'
|
|
165
|
+
import { createRuleFactory } from 'move-by-move'
|
|
166
|
+
|
|
167
|
+
const createRule = createRuleFactory<State, Action>(produce)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- `State` — your game state.
|
|
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.
|
|
172
|
+
|
|
173
|
+
`createRule` has two forms, both returning a `Rule<State, Action>`:
|
|
174
|
+
|
|
175
|
+
| Call | Runs | `action` type |
|
|
176
|
+
| --- | --- | --- |
|
|
177
|
+
| `createRule(recipe)` | on every action | the full `Action` union |
|
|
178
|
+
| `createRule(type, recipe)` | only when `action.type === type` | narrowed to that member |
|
|
179
|
+
|
|
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)).
|
|
181
|
+
|
|
182
|
+
### `Immutable<T>`
|
|
183
|
+
|
|
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`.
|
|
185
|
+
|
|
186
|
+
### `Game<State, Action>`
|
|
106
187
|
|
|
107
188
|
| Method | Description |
|
|
108
189
|
| --- | --- |
|
|
109
190
|
| `getState(): State` | Returns the current state. |
|
|
110
|
-
| `move(action:
|
|
191
|
+
| `move(action: Action): void` | Runs `action` through the rule pipeline, updates state, and notifies subscribers. |
|
|
111
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. |
|
|
112
193
|
|
|
113
|
-
### `GameAction
|
|
194
|
+
### `GameAction`
|
|
114
195
|
|
|
115
|
-
|
|
196
|
+
The base constraint every action satisfies — an object with a required `type` string:
|
|
116
197
|
|
|
117
198
|
```ts
|
|
118
|
-
type GameAction
|
|
199
|
+
type GameAction = { type: string }
|
|
119
200
|
```
|
|
120
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
|
+
|
|
121
204
|
## License
|
|
122
205
|
|
|
123
206
|
MIT © Andreas Riedmüller
|
package/lib/index.d.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
export type
|
|
1
|
+
export type Immutable<T> = 0 extends 1 & T ? T : T extends readonly (infer E)[] ? readonly Immutable<E>[] : T extends object ? {
|
|
2
|
+
readonly [K in keyof T]: Immutable<T[K]>;
|
|
3
|
+
} : T;
|
|
4
|
+
export type GameAction = {
|
|
2
5
|
type: string;
|
|
3
6
|
};
|
|
4
|
-
export interface Game<
|
|
7
|
+
export interface Game<State, Action extends GameAction> {
|
|
5
8
|
subscribe(listener: Function): () => void;
|
|
6
|
-
getState():
|
|
7
|
-
move(action:
|
|
9
|
+
getState(): State;
|
|
10
|
+
move(action: Action): void;
|
|
8
11
|
}
|
|
9
|
-
export type RuleIO<
|
|
10
|
-
export
|
|
12
|
+
export type RuleIO<State, Action extends GameAction> = [state: State, action: Action, context: Record<string, unknown>];
|
|
13
|
+
export type Rule<State, Action extends GameAction> = (input: RuleIO<Immutable<State>, Immutable<Action>>) => RuleIO<State, Action>;
|
|
14
|
+
export type Produce = <S>(base: S, recipe: (draft: S) => void) => S;
|
|
15
|
+
export declare function createRuleFactory<State, Action extends GameAction>(produce: Produce): {
|
|
16
|
+
(recipe: (state: State, action: Immutable<Action>, context: Record<string, unknown>) => void): Rule<State, Action>;
|
|
17
|
+
<Type extends Action["type"]>(type: Type, recipe: (state: State, action: Immutable<Extract<Action, {
|
|
18
|
+
type: Type;
|
|
19
|
+
}>>, context: Record<string, unknown>) => void): Rule<State, Action>;
|
|
20
|
+
};
|
|
21
|
+
export default function createGame<State, Action extends GameAction>(rules: Array<Rule<State, Action>>, initialGameState: State): Game<State, Action>;
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import { pipe } from './helpers.js';
|
|
2
|
+
export function createRuleFactory(produce) {
|
|
3
|
+
function createRule(typeOrRecipe, maybeRecipe) {
|
|
4
|
+
const type = typeof typeOrRecipe === 'string' ? typeOrRecipe : undefined;
|
|
5
|
+
const recipe = typeof typeOrRecipe === 'string' ? maybeRecipe : typeOrRecipe;
|
|
6
|
+
return ([state, action, context]) => {
|
|
7
|
+
if (type !== undefined && action.type !== type) {
|
|
8
|
+
return [state, action, context];
|
|
9
|
+
}
|
|
10
|
+
const nextState = produce(state, (draft) => {
|
|
11
|
+
recipe(draft, action, context);
|
|
12
|
+
});
|
|
13
|
+
return [nextState, action, context];
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return createRule;
|
|
17
|
+
}
|
|
2
18
|
export default function createGame(rules, initialGameState) {
|
|
3
19
|
let listeners = [];
|
|
4
20
|
let state = initialGameState;
|
|
@@ -13,8 +29,8 @@ export default function createGame(rules, initialGameState) {
|
|
|
13
29
|
return state;
|
|
14
30
|
};
|
|
15
31
|
const move = (action) => {
|
|
16
|
-
const result = pipe(...rules)([action,
|
|
17
|
-
state = result[
|
|
32
|
+
const result = pipe(...rules)([state, action, {}]);
|
|
33
|
+
state = result[0];
|
|
18
34
|
for (const listener of listeners) {
|
|
19
35
|
listener();
|
|
20
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "move-by-move",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Simple game engine for turn based games",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"lib"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"prepare": "npm run build",
|
|
20
|
-
"build": "tsc",
|
|
21
|
-
"test": "
|
|
19
|
+
"prepare": "npm run test && npm run build",
|
|
20
|
+
"build": "tsc -p tsconfig.lib.json",
|
|
21
|
+
"test": "npm run build && tsc -p tsconfig.test.json --noEmit && node --test test/*.test.ts"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
|
24
24
|
"game",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"author": "Andreas Riedmüller",
|
|
28
28
|
"license": "MIT",
|
|
29
29
|
"devDependencies": {
|
|
30
|
+
"@types/node": "^26.0.1",
|
|
31
|
+
"immer": "^11.1.8",
|
|
30
32
|
"typescript": "^6.0.3"
|
|
31
33
|
}
|
|
32
34
|
}
|