move-by-move 2.0.0 → 3.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 +99 -23
- package/lib/helpers.d.ts +1 -1
- package/lib/helpers.js +1 -5
- package/lib/index.d.ts +18 -5
- package/lib/index.js +20 -6
- package/package.json +16 -6
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 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.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -38,35 +38,46 @@ game.move({ type: 'INCREMENT', by: 5 }) // logs: state: { count: 6 }
|
|
|
38
38
|
unsubscribe() // stop listening
|
|
39
39
|
```
|
|
40
40
|
|
|
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
|
+
|
|
41
43
|
## Core concept: rules
|
|
42
44
|
|
|
43
|
-
A **rule** is a function over a `[action, state]` tuple:
|
|
45
|
+
A **rule** is a function over a `[action, state, context]` tuple:
|
|
44
46
|
|
|
45
47
|
```ts
|
|
46
|
-
type RuleIO<State> = [action: GameAction
|
|
47
|
-
type Rule<State> = (input: RuleIO<State>) => RuleIO<State>
|
|
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>
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
|
|
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
|
+
|
|
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:
|
|
51
55
|
|
|
52
56
|
```
|
|
53
|
-
[action, state] → rule1 → rule2 → rule3 → [action, newState]
|
|
54
|
-
|
|
57
|
+
[action, state, context] → rule1 → rule2 → rule3 → [action, newState, context]
|
|
58
|
+
└── becomes the new state
|
|
55
59
|
```
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
(`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
|
+
Because each rule receives the action, the (possibly already-transformed) state, and a shared context, rules can:
|
|
58
64
|
|
|
59
65
|
- **transform state** based on the action,
|
|
60
66
|
- **ignore** actions they don't care about (just return the input unchanged),
|
|
61
|
-
- **
|
|
67
|
+
- **share information** with later rules through the `context` bag,
|
|
62
68
|
- **run unconditionally**, e.g. a rule that switches the active player or checks for a winner after every move.
|
|
63
69
|
|
|
64
70
|
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
71
|
|
|
66
|
-
###
|
|
72
|
+
### Authoring rules with `createRule`
|
|
73
|
+
|
|
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
|
+
|
|
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:
|
|
67
77
|
|
|
68
78
|
```ts
|
|
69
|
-
import createGame from 'move-by-move'
|
|
79
|
+
import createGame, { createRuleFactory } from 'move-by-move'
|
|
80
|
+
import { produce } from 'immer'
|
|
70
81
|
|
|
71
82
|
type State = {
|
|
72
83
|
board: (string | null)[]
|
|
@@ -74,18 +85,31 @@ type State = {
|
|
|
74
85
|
winner: string | null
|
|
75
86
|
}
|
|
76
87
|
|
|
77
|
-
|
|
78
|
-
if (action.type !== 'PLACE' || state.winner) return [action, state]
|
|
79
|
-
if (state.board[action.index] !== null) return [action, state] // square taken
|
|
80
|
-
const board = [...state.board]
|
|
81
|
-
board[action.index] = state.turn
|
|
82
|
-
return [action, { ...state, board }]
|
|
83
|
-
}
|
|
88
|
+
type Action = { type: 'PLACE'; index: number }
|
|
84
89
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
const createRule = createRuleFactory<State, Action>(produce)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A recipe receives `(action, state, context)`:
|
|
94
|
+
|
|
95
|
+
- `action` — the action being processed, **read-only**.
|
|
96
|
+
- `state` — a **mutable draft**; mutate it directly, no spreading.
|
|
97
|
+
- `context` — a mutable bag **shared by every rule in this move** (see below).
|
|
98
|
+
|
|
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:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const placeMark = createRule('PLACE', (action, state, context) => {
|
|
103
|
+
if (state.winner) return
|
|
104
|
+
if (state.board[action.index] !== null) return // square already taken
|
|
105
|
+
state.board[action.index] = state.turn // action.index is typed: number
|
|
106
|
+
context.placed = true // tell downstream rules the move was accepted
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const switchTurn = createRule('PLACE', (action, state, context) => {
|
|
110
|
+
if (!context.placed) return // only switch when placeMark accepted the move
|
|
111
|
+
state.turn = state.turn === 'X' ? 'O' : 'X'
|
|
112
|
+
})
|
|
89
113
|
|
|
90
114
|
const game = createGame<State>(
|
|
91
115
|
[placeMark, switchTurn],
|
|
@@ -93,6 +117,30 @@ const game = createGame<State>(
|
|
|
93
117
|
)
|
|
94
118
|
```
|
|
95
119
|
|
|
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.
|
|
121
|
+
|
|
122
|
+
## Context: sharing data between rules
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
**Why not put this on `state` or the `action`?**
|
|
129
|
+
|
|
130
|
+
- `state` is your persistent game state — you don't want transient bookkeeping like "was this move valid?" leaking into it (and into your subscribers).
|
|
131
|
+
- the `action` is read-only, by design.
|
|
132
|
+
|
|
133
|
+
So `context` is a separate, mutable scratch space with a clear lifetime:
|
|
134
|
+
|
|
135
|
+
- 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
|
+
- 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
|
+
```
|
|
143
|
+
|
|
96
144
|
## API
|
|
97
145
|
|
|
98
146
|
### `createGame<State>(rules, initialState): Game<State>`
|
|
@@ -102,11 +150,39 @@ Creates a new game instance.
|
|
|
102
150
|
- `rules` — an array of rule functions, applied in order on every move.
|
|
103
151
|
- `initialState` — the starting state.
|
|
104
152
|
|
|
153
|
+
### `createRuleFactory<State, Action, Context>(produce)`
|
|
154
|
+
|
|
155
|
+
Binds immer's `produce` (which you supply — `move-by-move` is dependency-free) plus your game's types, and returns a `createRule` helper:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { produce } from 'immer'
|
|
159
|
+
import { createRuleFactory } from 'move-by-move'
|
|
160
|
+
|
|
161
|
+
const createRule = createRuleFactory<State, Action>(produce)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- `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>`.
|
|
167
|
+
|
|
168
|
+
`createRule` has two forms, both returning a `Rule<State, Context>`:
|
|
169
|
+
|
|
170
|
+
| Call | Runs | `action` type |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| `createRule(recipe)` | on every action | the full `Action` union |
|
|
173
|
+
| `createRule(type, recipe)` | only when `action.type === type` | narrowed to that member |
|
|
174
|
+
|
|
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)).
|
|
176
|
+
|
|
177
|
+
### `Immutable<T>`
|
|
178
|
+
|
|
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`.
|
|
180
|
+
|
|
105
181
|
### `Game<State>`
|
|
106
182
|
|
|
107
183
|
| Method | Description |
|
|
108
184
|
| --- | --- |
|
|
109
|
-
| `getState(): State
|
|
185
|
+
| `getState(): Immutable<State>` | Returns the current state, deeply read-only. |
|
|
110
186
|
| `move(action: GameAction): void` | Runs `action` through the rule pipeline, updates state, and notifies subscribers. |
|
|
111
187
|
| `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
188
|
|
package/lib/helpers.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const pipe: <T>(...fns: (
|
|
1
|
+
export declare const pipe: <T>(...fns: Array<(arg: T) => T>) => (value: T) => T;
|
package/lib/helpers.js
CHANGED
|
@@ -1,5 +1 @@
|
|
|
1
|
-
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.pipe = void 0;
|
|
4
|
-
const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);
|
|
5
|
-
exports.pipe = pipe;
|
|
1
|
+
export const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);
|
package/lib/index.d.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type GameAction<Payload = any> = Payload & {
|
|
2
2
|
type: string;
|
|
3
3
|
};
|
|
4
|
-
export
|
|
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> {
|
|
5
8
|
subscribe(listener: Function): () => void;
|
|
6
|
-
getState():
|
|
9
|
+
getState(): Immutable<State>;
|
|
7
10
|
move(action: GameAction): void;
|
|
8
11
|
}
|
|
9
|
-
export
|
|
10
|
-
export
|
|
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, {
|
|
20
|
+
type: Type;
|
|
21
|
+
}>>, state: State, context: Context) => void): Rule<State, Context>;
|
|
22
|
+
};
|
|
23
|
+
export default function createGame<State>(rules: Array<Rule<State>>, initialGameState: State): Game<State>;
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 ([action, state, context]) => {
|
|
7
|
+
const nextContext = (context ?? {});
|
|
8
|
+
if (type !== undefined && action.type !== type) {
|
|
9
|
+
return [action, state, nextContext];
|
|
10
|
+
}
|
|
11
|
+
const nextState = produce(state, (draft) => {
|
|
12
|
+
recipe(action, draft, nextContext);
|
|
13
|
+
});
|
|
14
|
+
return [action, nextState, nextContext];
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
return createRule;
|
|
18
|
+
}
|
|
19
|
+
export default function createGame(rules, initialGameState) {
|
|
5
20
|
let listeners = [];
|
|
6
21
|
let state = initialGameState;
|
|
7
22
|
const unsubscribe = (listener) => {
|
|
@@ -15,7 +30,7 @@ function createGame(rules, initialGameState) {
|
|
|
15
30
|
return state;
|
|
16
31
|
};
|
|
17
32
|
const move = (action) => {
|
|
18
|
-
const result =
|
|
33
|
+
const result = pipe(...rules)([action, state, {}]);
|
|
19
34
|
state = result[1];
|
|
20
35
|
for (const listener of listeners) {
|
|
21
36
|
listener();
|
|
@@ -27,4 +42,3 @@ function createGame(rules, initialGameState) {
|
|
|
27
42
|
move,
|
|
28
43
|
};
|
|
29
44
|
}
|
|
30
|
-
exports.default = createGame;
|
package/package.json
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "move-by-move",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Simple game engine for turn based games",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "lib/index.js",
|
|
6
|
-
"
|
|
7
|
+
"module": "lib/index.js",
|
|
8
|
+
"types": "lib/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./lib/index.d.ts",
|
|
12
|
+
"default": "./lib/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
7
15
|
"files": [
|
|
8
16
|
"lib"
|
|
9
17
|
],
|
|
10
18
|
"scripts": {
|
|
11
|
-
"prepare": "npm run build",
|
|
12
|
-
"build": "tsc",
|
|
13
|
-
"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"
|
|
14
22
|
},
|
|
15
23
|
"keywords": [
|
|
16
24
|
"game",
|
|
@@ -19,6 +27,8 @@
|
|
|
19
27
|
"author": "Andreas Riedmüller",
|
|
20
28
|
"license": "MIT",
|
|
21
29
|
"devDependencies": {
|
|
22
|
-
"
|
|
30
|
+
"@types/node": "^26.0.1",
|
|
31
|
+
"immer": "^11.1.8",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
23
33
|
}
|
|
24
34
|
}
|