@inachess/sdk 0.1.0 → 0.2.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 +105 -14
- package/dist/index.cjs +53 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -4
- package/dist/index.d.ts +55 -4
- package/dist/index.js +53 -3
- package/dist/index.js.map +1 -1
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# @inachess/sdk
|
|
2
2
|
|
|
3
|
-
JavaScript
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
TypeScript/JavaScript client for the **inachess-engine** game WebSocket.
|
|
4
|
+
|
|
5
|
+
Framework-agnostic, zero runtime dependencies, runs in the browser and Node. It
|
|
6
|
+
owns the wire protocol — join, moves, draws, latency pings, reconnect — so your
|
|
7
|
+
app never hand-rolls socket messages.
|
|
8
|
+
|
|
9
|
+
- **UCI everywhere.** Moves are UCI strings (`"e2e4"`), never SAN.
|
|
10
|
+
- **Server is authoritative.** The engine validates every move and computes every
|
|
11
|
+
clock; the client only sends intent. Rejections come back as an `error` event.
|
|
12
|
+
- **Clocks are milliseconds remaining**, sent by the server. Never compute them
|
|
13
|
+
client-side.
|
|
7
14
|
|
|
8
15
|
## Install
|
|
9
16
|
|
|
@@ -11,7 +18,7 @@ messages.
|
|
|
11
18
|
npm install @inachess/sdk
|
|
12
19
|
```
|
|
13
20
|
|
|
14
|
-
##
|
|
21
|
+
## Quick start
|
|
15
22
|
|
|
16
23
|
```ts
|
|
17
24
|
import { EngineClient } from "@inachess/sdk";
|
|
@@ -26,30 +33,114 @@ client.on("error", (e) => console.warn(e.message));
|
|
|
26
33
|
// Join with a single-use ticket minted by the platform.
|
|
27
34
|
client.connect({ ticket });
|
|
28
35
|
|
|
29
|
-
client.move("e2e4");
|
|
30
|
-
client.resign();
|
|
36
|
+
client.move("e2e4");
|
|
31
37
|
```
|
|
32
38
|
|
|
33
|
-
The client auto-answers the engine's latency pings and
|
|
34
|
-
engine's rotating reconnect token
|
|
39
|
+
The client auto-answers the engine's latency pings and, after an unexpected drop,
|
|
40
|
+
auto-reconnects using the engine's rotating reconnect token.
|
|
41
|
+
|
|
42
|
+
## Connect modes
|
|
35
43
|
|
|
36
44
|
```ts
|
|
37
|
-
client.connect({ ticket }); // first join
|
|
45
|
+
client.connect({ ticket }); // first join (single-use ticket)
|
|
38
46
|
client.connect({ reconnect: token }); // resume after a drop
|
|
39
|
-
client.connect({ spectate: token }); // read-only viewer
|
|
47
|
+
client.connect({ spectate: token }); // read-only viewer (no reconnect token)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Read state after connecting:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
client.seat; // "white" | "black" | "spectator" | null (before join)
|
|
54
|
+
client.reconnectToken; // rotating token, or null (spectators never get one)
|
|
40
55
|
```
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
## Events
|
|
58
|
+
|
|
59
|
+
Subscribe with `client.on(event, handler)`; unsubscribe with `client.off(...)`.
|
|
60
|
+
|
|
61
|
+
| Event | Payload | When |
|
|
62
|
+
|----------------|------------------------------------------------|------|
|
|
63
|
+
| `gameState` | full snapshot (`fen`, `turn`, `you`, `moves`, `status`, `whiteMs`, `blackMs`) | join / rejoin / spectate |
|
|
64
|
+
| `move` | `uci`, `by`, `fen`, `turn`, `whiteMs`, `blackMs` | a move was validated |
|
|
65
|
+
| `gameOver` | `result`, `reason` | game ended (`result`: `white_win` \| `black_win` \| `draw` \| `aborted`) |
|
|
66
|
+
| `drawOffer` | `by` | opponent offered a draw |
|
|
67
|
+
| `drawDeclined` | — | your draw offer was declined |
|
|
68
|
+
| `error` | `message` | a command was rejected |
|
|
69
|
+
| `open` | — | socket opened |
|
|
70
|
+
| `close` | `code`, `reason`, `willReconnect` | socket closed |
|
|
71
|
+
| `reconnecting` | `attempt` | a reconnect attempt is scheduled |
|
|
72
|
+
| `message` | any `ServerMessage` | catch-all, fires for every server message |
|
|
73
|
+
|
|
74
|
+
## Actions
|
|
75
|
+
|
|
76
|
+
Client → engine. All are no-ops if the socket is not open.
|
|
77
|
+
|
|
78
|
+
| Method | Sends | Purpose |
|
|
79
|
+
|--------------------|-----------------|---------|
|
|
80
|
+
| `move(uci)` | `move` | submit a UCI move |
|
|
81
|
+
| `resign()` | `resign` | resign the game |
|
|
82
|
+
| `abort()` | `abort` | abort before both sides' first move |
|
|
83
|
+
| `accept()` | `accept` | confirm presence under a `ready_check` gate |
|
|
84
|
+
| `offerDraw()` | `offer_draw` | propose a draw (rate-limited by the engine) |
|
|
85
|
+
| `acceptDraw()` | `accept_draw` | accept the opponent's pending offer |
|
|
86
|
+
| `declineDraw()` | `decline_draw` | decline the opponent's pending offer |
|
|
87
|
+
| `claimDraw()` | `claim_draw` | claim a threefold / 50-move draw when eligible |
|
|
88
|
+
| `close()` | — | close the socket and stop reconnecting |
|
|
89
|
+
|
|
90
|
+
## Node usage
|
|
91
|
+
|
|
92
|
+
There is no global `WebSocket` in Node, so pass an implementation:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { EngineClient } from "@inachess/sdk";
|
|
96
|
+
import { WebSocket } from "ws";
|
|
97
|
+
|
|
98
|
+
const client = new EngineClient({ url: "ws://localhost:8080/ws", WebSocket });
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Options
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
new EngineClient({
|
|
105
|
+
url: "wss://engine.example.com/ws", // required
|
|
106
|
+
WebSocket, // WebSocket impl; defaults to the global (browser)
|
|
107
|
+
autoReconnect: true, // reconnect after an unexpected drop (default true)
|
|
108
|
+
reconnectDelayMs: 500, // base backoff (default 500)
|
|
109
|
+
maxReconnectDelayMs: 8000,// backoff cap (default 8000)
|
|
110
|
+
maxReconnectAttempts: 10, // give up after this many failures (default 10)
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Reconnect uses exponential backoff (`reconnectDelayMs · 2ⁿ`, capped at
|
|
115
|
+
`maxReconnectDelayMs`). It only fires when `autoReconnect` is on **and** a
|
|
116
|
+
reconnect token exists — so spectators and games that ended (`gameOver`) never
|
|
117
|
+
reconnect.
|
|
118
|
+
|
|
119
|
+
## Rendering helpers
|
|
120
|
+
|
|
121
|
+
Pure, protocol-free helpers for drawing a board from a FEN:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { parseBoard, fenTurn, isPromotion, FILES } from "@inachess/sdk";
|
|
125
|
+
|
|
126
|
+
const board = parseBoard(fen); // { e1: "K", e8: "k", ... }
|
|
127
|
+
fenTurn(fen); // "white" | "black"
|
|
128
|
+
isPromotion(board, "e7", "e8"); // true if this move promotes a pawn
|
|
129
|
+
FILES; // ["a".."h"]
|
|
130
|
+
```
|
|
44
131
|
|
|
45
132
|
## Develop
|
|
46
133
|
|
|
47
134
|
```bash
|
|
48
135
|
npm install
|
|
49
|
-
npm run build # tsup → dist (ESM + CJS + .d.ts)
|
|
136
|
+
npm run build # tsup → dist/ (ESM + CJS + .d.ts)
|
|
50
137
|
npm test # node --test
|
|
51
138
|
```
|
|
52
139
|
|
|
140
|
+
`src/protocol.ts` mirrors the engine's `internal/game/room/protocol.go`
|
|
141
|
+
byte-for-byte — if the engine's wire protocol changes, that file changes in
|
|
142
|
+
lockstep. Consumers import from `dist/`, so rebuild after any change.
|
|
143
|
+
|
|
53
144
|
## License
|
|
54
145
|
|
|
55
146
|
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -36,9 +36,12 @@ var EngineClient = class {
|
|
|
36
36
|
autoReconnect;
|
|
37
37
|
baseDelay;
|
|
38
38
|
maxDelay;
|
|
39
|
+
maxAttempts;
|
|
39
40
|
ws = null;
|
|
40
41
|
mode = null;
|
|
41
42
|
closedByUser = false;
|
|
43
|
+
finished = false;
|
|
44
|
+
// game_over seen → terminal, no auto-reconnect
|
|
42
45
|
attempts = 0;
|
|
43
46
|
timer = null;
|
|
44
47
|
_reconnectToken = null;
|
|
@@ -52,6 +55,7 @@ var EngineClient = class {
|
|
|
52
55
|
this.autoReconnect = opts.autoReconnect ?? true;
|
|
53
56
|
this.baseDelay = opts.reconnectDelayMs ?? 500;
|
|
54
57
|
this.maxDelay = opts.maxReconnectDelayMs ?? 8e3;
|
|
58
|
+
this.maxAttempts = opts.maxReconnectAttempts ?? 10;
|
|
55
59
|
}
|
|
56
60
|
/** The current rotating reconnect token, or null (spectators never get one). */
|
|
57
61
|
get reconnectToken() {
|
|
@@ -76,7 +80,9 @@ var EngineClient = class {
|
|
|
76
80
|
}
|
|
77
81
|
/** Open the socket with the given credentials. */
|
|
78
82
|
connect(mode) {
|
|
83
|
+
if (this.timer) clearTimeout(this.timer);
|
|
79
84
|
this.closedByUser = false;
|
|
85
|
+
this.finished = false;
|
|
80
86
|
this.mode = mode;
|
|
81
87
|
this.attempts = 0;
|
|
82
88
|
this.dial();
|
|
@@ -89,6 +95,30 @@ var EngineClient = class {
|
|
|
89
95
|
resign() {
|
|
90
96
|
this.send({ type: "resign" });
|
|
91
97
|
}
|
|
98
|
+
/** Offer a draw to the opponent (rate-limited by the engine). */
|
|
99
|
+
offerDraw() {
|
|
100
|
+
this.send({ type: "offer_draw" });
|
|
101
|
+
}
|
|
102
|
+
/** Accept the opponent's pending draw offer. */
|
|
103
|
+
acceptDraw() {
|
|
104
|
+
this.send({ type: "accept_draw" });
|
|
105
|
+
}
|
|
106
|
+
/** Decline the opponent's pending draw offer. */
|
|
107
|
+
declineDraw() {
|
|
108
|
+
this.send({ type: "decline_draw" });
|
|
109
|
+
}
|
|
110
|
+
/** Claim a threefold-repetition / 50-move draw when eligible. */
|
|
111
|
+
claimDraw() {
|
|
112
|
+
this.send({ type: "claim_draw" });
|
|
113
|
+
}
|
|
114
|
+
/** Abort the game before both sides have made their first move. */
|
|
115
|
+
abort() {
|
|
116
|
+
this.send({ type: "abort" });
|
|
117
|
+
}
|
|
118
|
+
/** Confirm presence under a ready_check gate. */
|
|
119
|
+
accept() {
|
|
120
|
+
this.send({ type: "accept" });
|
|
121
|
+
}
|
|
92
122
|
/** Close the socket and stop any reconnection. */
|
|
93
123
|
close() {
|
|
94
124
|
this.closedByUser = true;
|
|
@@ -98,10 +128,16 @@ var EngineClient = class {
|
|
|
98
128
|
}
|
|
99
129
|
dial() {
|
|
100
130
|
if (!this.mode) return;
|
|
131
|
+
if (this.ws) {
|
|
132
|
+
this.ws.onopen = this.ws.onmessage = this.ws.onclose = this.ws.onerror = null;
|
|
133
|
+
try {
|
|
134
|
+
this.ws.close(1e3, "");
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
}
|
|
101
138
|
const ws = new this.WS(this.url + "?" + queryFor(this.mode));
|
|
102
139
|
this.ws = ws;
|
|
103
140
|
ws.onopen = () => {
|
|
104
|
-
this.attempts = 0;
|
|
105
141
|
this.emit("open", void 0);
|
|
106
142
|
};
|
|
107
143
|
ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
@@ -116,9 +152,11 @@ var EngineClient = class {
|
|
|
116
152
|
} catch {
|
|
117
153
|
return;
|
|
118
154
|
}
|
|
155
|
+
if (!msg || typeof msg.type !== "string") return;
|
|
119
156
|
this.emit("message", msg);
|
|
120
157
|
switch (msg.type) {
|
|
121
158
|
case "game_state":
|
|
159
|
+
this.attempts = 0;
|
|
122
160
|
if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;
|
|
123
161
|
this._seat = msg.you;
|
|
124
162
|
this.emit("gameState", msg);
|
|
@@ -127,11 +165,18 @@ var EngineClient = class {
|
|
|
127
165
|
this.emit("move", msg);
|
|
128
166
|
break;
|
|
129
167
|
case "game_over":
|
|
168
|
+
this.finished = true;
|
|
130
169
|
this.emit("gameOver", msg);
|
|
131
170
|
break;
|
|
132
171
|
case "error":
|
|
133
172
|
this.emit("error", msg);
|
|
134
173
|
break;
|
|
174
|
+
case "draw_offer":
|
|
175
|
+
this.emit("drawOffer", msg);
|
|
176
|
+
break;
|
|
177
|
+
case "draw_declined":
|
|
178
|
+
this.emit("drawDeclined", msg);
|
|
179
|
+
break;
|
|
135
180
|
case "ping":
|
|
136
181
|
this.send({ type: "pong", t: msg.t });
|
|
137
182
|
break;
|
|
@@ -139,16 +184,21 @@ var EngineClient = class {
|
|
|
139
184
|
}
|
|
140
185
|
handleClose(code, reason) {
|
|
141
186
|
this.ws = null;
|
|
142
|
-
const willReconnect = !this.closedByUser && this.autoReconnect && this._reconnectToken !== null;
|
|
187
|
+
const willReconnect = !this.closedByUser && !this.finished && this.autoReconnect && this._reconnectToken !== null;
|
|
143
188
|
this.emit("close", { code, reason, willReconnect });
|
|
144
189
|
if (willReconnect) this.scheduleReconnect();
|
|
145
190
|
}
|
|
146
191
|
scheduleReconnect() {
|
|
147
192
|
this.attempts += 1;
|
|
193
|
+
if (this.attempts > this.maxAttempts) {
|
|
194
|
+
this._reconnectToken = null;
|
|
195
|
+
this.emit("close", { code: 1006, reason: "reconnect gave up", willReconnect: false });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
148
198
|
const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);
|
|
149
199
|
this.emit("reconnecting", { attempt: this.attempts });
|
|
150
200
|
this.timer = setTimeout(() => {
|
|
151
|
-
if (this.closedByUser || this._reconnectToken === null) return;
|
|
201
|
+
if (this.closedByUser || this.finished || this._reconnectToken === null) return;
|
|
152
202
|
this.mode = { reconnect: this._reconnectToken };
|
|
153
203
|
this.dial();
|
|
154
204
|
}, delay);
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/client.ts","../src/fen.ts"],"sourcesContent":["export { EngineClient } from \"./client.js\";\nexport type {\n ConnectMode,\n EngineClientOptions,\n EngineClientEvents,\n} from \"./client.js\";\nexport type {\n Color,\n Seat,\n GameStatus,\n GameResult,\n ServerMessage,\n ClientMessage,\n GameStateMessage,\n MoveMessage,\n GameOverMessage,\n ErrorMessage,\n PingMessage,\n} from \"./protocol.js\";\nexport { parseBoard, fenTurn, isPromotion, FILES } from \"./fen.js\";\nexport type { PieceChar } from \"./fen.js\";\n","import type {\n ServerMessage,\n GameStateMessage,\n MoveMessage,\n GameOverMessage,\n ErrorMessage,\n Seat,\n} from \"./protocol.js\";\n\n/** How to authenticate the socket to a room. */\nexport type ConnectMode =\n | { ticket: string }\n | { reconnect: string }\n | { spectate: string };\n\n/** Events emitted by EngineClient. */\nexport interface EngineClientEvents {\n open: void;\n close: { code: number; reason: string; willReconnect: boolean };\n reconnecting: { attempt: number };\n gameState: GameStateMessage;\n move: MoveMessage;\n gameOver: GameOverMessage;\n error: ErrorMessage;\n message: ServerMessage;\n}\n\nexport interface EngineClientOptions {\n /** Base socket URL, e.g. \"wss://engine.example.com/ws\". */\n url: string;\n /** WebSocket implementation; defaults to the global (browser). Pass `ws` in Node. */\n WebSocket?: unknown;\n /** Auto-reconnect with the rotating token after an unexpected drop (default true). */\n autoReconnect?: boolean;\n reconnectDelayMs?: number;\n maxReconnectDelayMs?: number;\n}\n\ntype Listener<T> = (payload: T) => void;\ntype WSCtor = new (url: string) => WebSocketLike;\ninterface WebSocketLike {\n readyState: number;\n send(data: string): void;\n close(code?: number, reason?: string): void;\n onopen: ((ev: unknown) => void) | null;\n onmessage: ((ev: { data: unknown }) => void) | null;\n onclose: ((ev: { code: number; reason: string }) => void) | null;\n onerror: ((ev: unknown) => void) | null;\n}\n\nconst OPEN = 1;\n\n/**\n * EngineClient wraps one game socket to the inachess-engine. It handles the wire\n * protocol end to end: parses server messages, auto-answers latency pings, tracks\n * the rotating reconnect token, and (by default) transparently reconnects after a\n * drop. Framework-agnostic — the platform routes a player to the play view with a\n * ticket, then hands that ticket to this client.\n *\n * ```ts\n * const c = new EngineClient({ url: \"wss://engine/ws\" });\n * c.on(\"gameState\", s => render(s));\n * c.on(\"move\", m => render(m));\n * c.connect({ ticket });\n * c.move(\"e2e4\");\n * ```\n */\nexport class EngineClient {\n private readonly url: string;\n private readonly WS: WSCtor;\n private readonly autoReconnect: boolean;\n private readonly baseDelay: number;\n private readonly maxDelay: number;\n\n private ws: WebSocketLike | null = null;\n private mode: ConnectMode | null = null;\n private closedByUser = false;\n private attempts = 0;\n private timer: ReturnType<typeof setTimeout> | null = null;\n\n private _reconnectToken: string | null = null;\n private _seat: Seat | null = null;\n private listeners = new Map<keyof EngineClientEvents, Set<Listener<unknown>>>();\n\n constructor(opts: EngineClientOptions) {\n this.url = opts.url;\n const WS = (opts.WebSocket ?? (globalThis as { WebSocket?: unknown }).WebSocket) as WSCtor | undefined;\n if (!WS) throw new Error(\"no WebSocket implementation available; pass options.WebSocket\");\n this.WS = WS;\n this.autoReconnect = opts.autoReconnect ?? true;\n this.baseDelay = opts.reconnectDelayMs ?? 500;\n this.maxDelay = opts.maxReconnectDelayMs ?? 8000;\n }\n\n /** The current rotating reconnect token, or null (spectators never get one). */\n get reconnectToken(): string | null {\n return this._reconnectToken;\n }\n\n /** The recipient's seat: \"white\" | \"black\" | \"spectator\" | null (before join). */\n get seat(): Seat | null {\n return this._seat;\n }\n\n on<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n let set = this.listeners.get(event);\n if (!set) {\n set = new Set();\n this.listeners.set(event, set);\n }\n set.add(handler as Listener<unknown>);\n return this;\n }\n\n off<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n this.listeners.get(event)?.delete(handler as Listener<unknown>);\n return this;\n }\n\n /** Open the socket with the given credentials. */\n connect(mode: ConnectMode): void {\n this.closedByUser = false;\n this.mode = mode;\n this.attempts = 0;\n this.dial();\n }\n\n /** Submit a move (UCI). No-op if the socket is not open. */\n move(uci: string): void {\n this.send({ type: \"move\", uci });\n }\n\n /** Resign the game. */\n resign(): void {\n this.send({ type: \"resign\" });\n }\n\n /** Close the socket and stop any reconnection. */\n close(): void {\n this.closedByUser = true;\n if (this.timer) clearTimeout(this.timer);\n this.ws?.close(1000, \"\");\n this.ws = null;\n }\n\n private dial(): void {\n if (!this.mode) return;\n const ws = new this.WS(this.url + \"?\" + queryFor(this.mode));\n this.ws = ws;\n ws.onopen = () => {\n this.attempts = 0;\n this.emit(\"open\", undefined);\n };\n ws.onmessage = (ev) => this.handleMessage(ev.data);\n ws.onclose = (ev) => this.handleClose(ev.code, ev.reason);\n ws.onerror = () => {}; // a close event always follows\n }\n\n private handleMessage(data: unknown): void {\n let msg: ServerMessage;\n try {\n msg = JSON.parse(typeof data === \"string\" ? data : String(data)) as ServerMessage;\n } catch {\n return;\n }\n this.emit(\"message\", msg);\n switch (msg.type) {\n case \"game_state\":\n if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;\n this._seat = msg.you;\n this.emit(\"gameState\", msg);\n break;\n case \"move\":\n this.emit(\"move\", msg);\n break;\n case \"game_over\":\n this.emit(\"gameOver\", msg);\n break;\n case \"error\":\n this.emit(\"error\", msg);\n break;\n case \"ping\":\n this.send({ type: \"pong\", t: msg.t });\n break;\n }\n }\n\n private handleClose(code: number, reason: string): void {\n this.ws = null;\n const willReconnect = !this.closedByUser && this.autoReconnect && this._reconnectToken !== null;\n this.emit(\"close\", { code, reason, willReconnect });\n if (willReconnect) this.scheduleReconnect();\n }\n\n private scheduleReconnect(): void {\n this.attempts += 1;\n const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);\n this.emit(\"reconnecting\", { attempt: this.attempts });\n this.timer = setTimeout(() => {\n if (this.closedByUser || this._reconnectToken === null) return;\n this.mode = { reconnect: this._reconnectToken };\n this.dial();\n }, delay);\n }\n\n private send(obj: unknown): void {\n if (this.ws && this.ws.readyState === OPEN) this.ws.send(JSON.stringify(obj));\n }\n\n private emit<K extends keyof EngineClientEvents>(event: K, payload: EngineClientEvents[K]): void {\n this.listeners.get(event)?.forEach((h) => h(payload));\n }\n}\n\nfunction queryFor(mode: ConnectMode): string {\n if (\"ticket\" in mode) return \"ticket=\" + encodeURIComponent(mode.ticket);\n if (\"reconnect\" in mode) return \"reconnect=\" + encodeURIComponent(mode.reconnect);\n return \"spectate=\" + encodeURIComponent(mode.spectate);\n}\n","import type { Color } from \"./protocol.js\";\n\nexport type PieceChar = \"K\" | \"Q\" | \"R\" | \"B\" | \"N\" | \"P\" | \"k\" | \"q\" | \"r\" | \"b\" | \"n\" | \"p\";\n\nexport const FILES = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\", \"h\"] as const;\n\n/** parseBoard turns a FEN into a map of square (\"e4\") → piece char. */\nexport function parseBoard(fen: string): Record<string, PieceChar> {\n const rows = fen.split(\" \")[0].split(\"/\");\n const out: Record<string, PieceChar> = {};\n for (let i = 0; i < 8; i++) {\n const rank = 8 - i;\n let file = 0;\n for (const ch of rows[i] ?? \"\") {\n if (ch >= \"1\" && ch <= \"8\") {\n file += Number(ch);\n } else {\n out[FILES[file] + rank] = ch as PieceChar;\n file += 1;\n }\n }\n }\n return out;\n}\n\n/** fenTurn returns the side to move encoded in a FEN. */\nexport function fenTurn(fen: string): Color {\n return fen.split(\" \")[1] === \"b\" ? \"black\" : \"white\";\n}\n\n/** isPromotion reports whether moving the piece on `from` to `to` is a pawn\n * reaching the last rank — the UI appends a promotion piece (default queen). */\nexport function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean {\n const p = board[from];\n return (p === \"P\" && to[1] === \"8\") || (p === \"p\" && to[1] === \"1\");\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/client.ts","../src/fen.ts"],"sourcesContent":["export { EngineClient } from \"./client.js\";\nexport type {\n ConnectMode,\n EngineClientOptions,\n EngineClientEvents,\n} from \"./client.js\";\nexport type {\n Color,\n Seat,\n GameStatus,\n GameResult,\n ServerMessage,\n ClientMessage,\n GameStateMessage,\n MoveMessage,\n GameOverMessage,\n ErrorMessage,\n PingMessage,\n DrawOfferMessage,\n DrawDeclinedMessage,\n} from \"./protocol.js\";\nexport { parseBoard, fenTurn, isPromotion, FILES } from \"./fen.js\";\nexport type { PieceChar } from \"./fen.js\";\n","import type {\n ServerMessage,\n GameStateMessage,\n MoveMessage,\n GameOverMessage,\n ErrorMessage,\n DrawOfferMessage,\n DrawDeclinedMessage,\n Seat,\n} from \"./protocol.js\";\n\n/** How to authenticate the socket to a room. */\nexport type ConnectMode =\n | { ticket: string }\n | { reconnect: string }\n | { spectate: string };\n\n/** Events emitted by EngineClient. */\nexport interface EngineClientEvents {\n open: void;\n close: { code: number; reason: string; willReconnect: boolean };\n reconnecting: { attempt: number };\n gameState: GameStateMessage;\n move: MoveMessage;\n gameOver: GameOverMessage;\n error: ErrorMessage;\n drawOffer: DrawOfferMessage;\n drawDeclined: DrawDeclinedMessage;\n message: ServerMessage;\n}\n\nexport interface EngineClientOptions {\n /** Base socket URL, e.g. \"wss://engine.example.com/ws\". */\n url: string;\n /** WebSocket implementation; defaults to the global (browser). Pass `ws` in Node. */\n WebSocket?: unknown;\n /** Auto-reconnect with the rotating token after an unexpected drop (default true). */\n autoReconnect?: boolean;\n reconnectDelayMs?: number;\n maxReconnectDelayMs?: number;\n /** Give up reconnecting after this many consecutive failed attempts (default 10). */\n maxReconnectAttempts?: number;\n}\n\ntype Listener<T> = (payload: T) => void;\ntype WSCtor = new (url: string) => WebSocketLike;\ninterface WebSocketLike {\n readyState: number;\n send(data: string): void;\n close(code?: number, reason?: string): void;\n onopen: ((ev: unknown) => void) | null;\n onmessage: ((ev: { data: unknown }) => void) | null;\n onclose: ((ev: { code: number; reason: string }) => void) | null;\n onerror: ((ev: unknown) => void) | null;\n}\n\nconst OPEN = 1;\n\n/**\n * EngineClient wraps one game socket to the inachess-engine. It handles the wire\n * protocol end to end: parses server messages, auto-answers latency pings, tracks\n * the rotating reconnect token, and (by default) transparently reconnects after a\n * drop. Framework-agnostic — the platform routes a player to the play view with a\n * ticket, then hands that ticket to this client.\n *\n * ```ts\n * const c = new EngineClient({ url: \"wss://engine/ws\" });\n * c.on(\"gameState\", s => render(s));\n * c.on(\"move\", m => render(m));\n * c.connect({ ticket });\n * c.move(\"e2e4\");\n * ```\n */\nexport class EngineClient {\n private readonly url: string;\n private readonly WS: WSCtor;\n private readonly autoReconnect: boolean;\n private readonly baseDelay: number;\n private readonly maxDelay: number;\n\n private readonly maxAttempts: number;\n\n private ws: WebSocketLike | null = null;\n private mode: ConnectMode | null = null;\n private closedByUser = false;\n private finished = false; // game_over seen → terminal, no auto-reconnect\n private attempts = 0;\n private timer: ReturnType<typeof setTimeout> | null = null;\n\n private _reconnectToken: string | null = null;\n private _seat: Seat | null = null;\n private listeners = new Map<keyof EngineClientEvents, Set<Listener<unknown>>>();\n\n constructor(opts: EngineClientOptions) {\n this.url = opts.url;\n const WS = (opts.WebSocket ?? (globalThis as { WebSocket?: unknown }).WebSocket) as WSCtor | undefined;\n if (!WS) throw new Error(\"no WebSocket implementation available; pass options.WebSocket\");\n this.WS = WS;\n this.autoReconnect = opts.autoReconnect ?? true;\n this.baseDelay = opts.reconnectDelayMs ?? 500;\n this.maxDelay = opts.maxReconnectDelayMs ?? 8000;\n this.maxAttempts = opts.maxReconnectAttempts ?? 10;\n }\n\n /** The current rotating reconnect token, or null (spectators never get one). */\n get reconnectToken(): string | null {\n return this._reconnectToken;\n }\n\n /** The recipient's seat: \"white\" | \"black\" | \"spectator\" | null (before join). */\n get seat(): Seat | null {\n return this._seat;\n }\n\n on<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n let set = this.listeners.get(event);\n if (!set) {\n set = new Set();\n this.listeners.set(event, set);\n }\n set.add(handler as Listener<unknown>);\n return this;\n }\n\n off<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n this.listeners.get(event)?.delete(handler as Listener<unknown>);\n return this;\n }\n\n /** Open the socket with the given credentials. */\n connect(mode: ConnectMode): void {\n if (this.timer) clearTimeout(this.timer); // cancel a pending reconnect so it can't clobber this socket\n this.closedByUser = false;\n this.finished = false;\n this.mode = mode;\n this.attempts = 0;\n this.dial();\n }\n\n /** Submit a move (UCI). No-op if the socket is not open. */\n move(uci: string): void {\n this.send({ type: \"move\", uci });\n }\n\n /** Resign the game. */\n resign(): void {\n this.send({ type: \"resign\" });\n }\n\n /** Offer a draw to the opponent (rate-limited by the engine). */\n offerDraw(): void {\n this.send({ type: \"offer_draw\" });\n }\n\n /** Accept the opponent's pending draw offer. */\n acceptDraw(): void {\n this.send({ type: \"accept_draw\" });\n }\n\n /** Decline the opponent's pending draw offer. */\n declineDraw(): void {\n this.send({ type: \"decline_draw\" });\n }\n\n /** Claim a threefold-repetition / 50-move draw when eligible. */\n claimDraw(): void {\n this.send({ type: \"claim_draw\" });\n }\n\n /** Abort the game before both sides have made their first move. */\n abort(): void {\n this.send({ type: \"abort\" });\n }\n\n /** Confirm presence under a ready_check gate. */\n accept(): void {\n this.send({ type: \"accept\" });\n }\n\n /** Close the socket and stop any reconnection. */\n close(): void {\n this.closedByUser = true;\n if (this.timer) clearTimeout(this.timer);\n this.ws?.close(1000, \"\");\n this.ws = null;\n }\n\n private dial(): void {\n if (!this.mode) return;\n if (this.ws) {\n // Detach + close any previous socket so its late events can't drive this\n // client or spawn a second reconnect chain.\n this.ws.onopen = this.ws.onmessage = this.ws.onclose = this.ws.onerror = null;\n try {\n this.ws.close(1000, \"\");\n } catch {\n /* closing a still-CONNECTING socket can throw; ignore */\n }\n }\n const ws = new this.WS(this.url + \"?\" + queryFor(this.mode));\n this.ws = ws;\n ws.onopen = () => {\n // Do NOT reset attempts here — a rejected reconnect still fires onopen\n // before the server's policy close. Reset only on a real join (game_state).\n this.emit(\"open\", undefined);\n };\n ws.onmessage = (ev) => this.handleMessage(ev.data);\n ws.onclose = (ev) => this.handleClose(ev.code, ev.reason);\n ws.onerror = () => {}; // a close event always follows\n }\n\n private handleMessage(data: unknown): void {\n let msg: ServerMessage;\n try {\n msg = JSON.parse(typeof data === \"string\" ? data : String(data)) as ServerMessage;\n } catch {\n return;\n }\n if (!msg || typeof (msg as { type?: unknown }).type !== \"string\") return;\n this.emit(\"message\", msg);\n switch (msg.type) {\n case \"game_state\":\n this.attempts = 0; // a real join succeeded → reset backoff\n if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;\n this._seat = msg.you;\n this.emit(\"gameState\", msg);\n break;\n case \"move\":\n this.emit(\"move\", msg);\n break;\n case \"game_over\":\n this.finished = true; // terminal — a later socket close must not reconnect\n this.emit(\"gameOver\", msg);\n break;\n case \"error\":\n this.emit(\"error\", msg);\n break;\n case \"draw_offer\":\n this.emit(\"drawOffer\", msg);\n break;\n case \"draw_declined\":\n this.emit(\"drawDeclined\", msg);\n break;\n case \"ping\":\n this.send({ type: \"pong\", t: msg.t });\n break;\n }\n }\n\n private handleClose(code: number, reason: string): void {\n this.ws = null;\n const willReconnect =\n !this.closedByUser && !this.finished && this.autoReconnect && this._reconnectToken !== null;\n this.emit(\"close\", { code, reason, willReconnect });\n if (willReconnect) this.scheduleReconnect();\n }\n\n private scheduleReconnect(): void {\n this.attempts += 1;\n if (this.attempts > this.maxAttempts) {\n // A consumed/invalid token would otherwise retry forever. Give up terminally.\n this._reconnectToken = null;\n this.emit(\"close\", { code: 1006, reason: \"reconnect gave up\", willReconnect: false });\n return;\n }\n const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);\n this.emit(\"reconnecting\", { attempt: this.attempts });\n this.timer = setTimeout(() => {\n if (this.closedByUser || this.finished || this._reconnectToken === null) return;\n this.mode = { reconnect: this._reconnectToken };\n this.dial();\n }, delay);\n }\n\n private send(obj: unknown): void {\n if (this.ws && this.ws.readyState === OPEN) this.ws.send(JSON.stringify(obj));\n }\n\n private emit<K extends keyof EngineClientEvents>(event: K, payload: EngineClientEvents[K]): void {\n this.listeners.get(event)?.forEach((h) => h(payload));\n }\n}\n\nfunction queryFor(mode: ConnectMode): string {\n if (\"ticket\" in mode) return \"ticket=\" + encodeURIComponent(mode.ticket);\n if (\"reconnect\" in mode) return \"reconnect=\" + encodeURIComponent(mode.reconnect);\n return \"spectate=\" + encodeURIComponent(mode.spectate);\n}\n","import type { Color } from \"./protocol.js\";\n\nexport type PieceChar = \"K\" | \"Q\" | \"R\" | \"B\" | \"N\" | \"P\" | \"k\" | \"q\" | \"r\" | \"b\" | \"n\" | \"p\";\n\nexport const FILES = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\", \"h\"] as const;\n\n/** parseBoard turns a FEN into a map of square (\"e4\") → piece char. */\nexport function parseBoard(fen: string): Record<string, PieceChar> {\n const rows = fen.split(\" \")[0].split(\"/\");\n const out: Record<string, PieceChar> = {};\n for (let i = 0; i < 8; i++) {\n const rank = 8 - i;\n let file = 0;\n for (const ch of rows[i] ?? \"\") {\n if (ch >= \"1\" && ch <= \"8\") {\n file += Number(ch);\n } else {\n out[FILES[file] + rank] = ch as PieceChar;\n file += 1;\n }\n }\n }\n return out;\n}\n\n/** fenTurn returns the side to move encoded in a FEN. */\nexport function fenTurn(fen: string): Color {\n return fen.split(\" \")[1] === \"b\" ? \"black\" : \"white\";\n}\n\n/** isPromotion reports whether moving the piece on `from` to `to` is a pawn\n * reaching the last rank — the UI appends a promotion piece (default queen). */\nexport function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean {\n const p = board[from];\n return (p === \"P\" && to[1] === \"8\") || (p === \"p\" && to[1] === \"1\");\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
type Color = "white" | "black";
|
|
2
2
|
type Seat = Color | "spectator";
|
|
3
3
|
type GameStatus = "active" | "finished";
|
|
4
|
-
type GameResult = "white_win" | "black_win" | "draw";
|
|
4
|
+
type GameResult = "white_win" | "black_win" | "draw" | "aborted";
|
|
5
5
|
/** Full snapshot sent on join / rejoin / spectate. */
|
|
6
6
|
interface GameStateMessage {
|
|
7
7
|
type: "game_state";
|
|
@@ -41,7 +41,16 @@ interface PingMessage {
|
|
|
41
41
|
type: "ping";
|
|
42
42
|
t: number;
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
/** Sent to the opponent when a player offers a draw. */
|
|
45
|
+
interface DrawOfferMessage {
|
|
46
|
+
type: "draw_offer";
|
|
47
|
+
by: Color;
|
|
48
|
+
}
|
|
49
|
+
/** Sent to the offerer when the opponent declines their draw offer. */
|
|
50
|
+
interface DrawDeclinedMessage {
|
|
51
|
+
type: "draw_declined";
|
|
52
|
+
}
|
|
53
|
+
type ServerMessage = GameStateMessage | MoveMessage | GameOverMessage | ErrorMessage | PingMessage | DrawOfferMessage | DrawDeclinedMessage;
|
|
45
54
|
interface MoveIntent {
|
|
46
55
|
type: "move";
|
|
47
56
|
uci: string;
|
|
@@ -53,7 +62,31 @@ interface PongIntent {
|
|
|
53
62
|
type: "pong";
|
|
54
63
|
t: number;
|
|
55
64
|
}
|
|
56
|
-
|
|
65
|
+
/** Propose a draw to the opponent (rate-limited by the engine). */
|
|
66
|
+
interface OfferDrawIntent {
|
|
67
|
+
type: "offer_draw";
|
|
68
|
+
}
|
|
69
|
+
/** Accept the opponent's pending draw offer. */
|
|
70
|
+
interface AcceptDrawIntent {
|
|
71
|
+
type: "accept_draw";
|
|
72
|
+
}
|
|
73
|
+
/** Decline the opponent's pending draw offer. */
|
|
74
|
+
interface DeclineDrawIntent {
|
|
75
|
+
type: "decline_draw";
|
|
76
|
+
}
|
|
77
|
+
/** Claim a threefold-repetition / 50-move draw when eligible. */
|
|
78
|
+
interface ClaimDrawIntent {
|
|
79
|
+
type: "claim_draw";
|
|
80
|
+
}
|
|
81
|
+
/** Abort the game before both sides have made their first move. */
|
|
82
|
+
interface AbortIntent {
|
|
83
|
+
type: "abort";
|
|
84
|
+
}
|
|
85
|
+
/** Confirm presence under a ready_check gate. */
|
|
86
|
+
interface AcceptIntent {
|
|
87
|
+
type: "accept";
|
|
88
|
+
}
|
|
89
|
+
type ClientMessage = MoveIntent | ResignIntent | PongIntent | OfferDrawIntent | AcceptDrawIntent | DeclineDrawIntent | ClaimDrawIntent | AbortIntent | AcceptIntent;
|
|
57
90
|
|
|
58
91
|
/** How to authenticate the socket to a room. */
|
|
59
92
|
type ConnectMode = {
|
|
@@ -78,6 +111,8 @@ interface EngineClientEvents {
|
|
|
78
111
|
move: MoveMessage;
|
|
79
112
|
gameOver: GameOverMessage;
|
|
80
113
|
error: ErrorMessage;
|
|
114
|
+
drawOffer: DrawOfferMessage;
|
|
115
|
+
drawDeclined: DrawDeclinedMessage;
|
|
81
116
|
message: ServerMessage;
|
|
82
117
|
}
|
|
83
118
|
interface EngineClientOptions {
|
|
@@ -89,6 +124,8 @@ interface EngineClientOptions {
|
|
|
89
124
|
autoReconnect?: boolean;
|
|
90
125
|
reconnectDelayMs?: number;
|
|
91
126
|
maxReconnectDelayMs?: number;
|
|
127
|
+
/** Give up reconnecting after this many consecutive failed attempts (default 10). */
|
|
128
|
+
maxReconnectAttempts?: number;
|
|
92
129
|
}
|
|
93
130
|
type Listener<T> = (payload: T) => void;
|
|
94
131
|
/**
|
|
@@ -112,9 +149,11 @@ declare class EngineClient {
|
|
|
112
149
|
private readonly autoReconnect;
|
|
113
150
|
private readonly baseDelay;
|
|
114
151
|
private readonly maxDelay;
|
|
152
|
+
private readonly maxAttempts;
|
|
115
153
|
private ws;
|
|
116
154
|
private mode;
|
|
117
155
|
private closedByUser;
|
|
156
|
+
private finished;
|
|
118
157
|
private attempts;
|
|
119
158
|
private timer;
|
|
120
159
|
private _reconnectToken;
|
|
@@ -133,6 +172,18 @@ declare class EngineClient {
|
|
|
133
172
|
move(uci: string): void;
|
|
134
173
|
/** Resign the game. */
|
|
135
174
|
resign(): void;
|
|
175
|
+
/** Offer a draw to the opponent (rate-limited by the engine). */
|
|
176
|
+
offerDraw(): void;
|
|
177
|
+
/** Accept the opponent's pending draw offer. */
|
|
178
|
+
acceptDraw(): void;
|
|
179
|
+
/** Decline the opponent's pending draw offer. */
|
|
180
|
+
declineDraw(): void;
|
|
181
|
+
/** Claim a threefold-repetition / 50-move draw when eligible. */
|
|
182
|
+
claimDraw(): void;
|
|
183
|
+
/** Abort the game before both sides have made their first move. */
|
|
184
|
+
abort(): void;
|
|
185
|
+
/** Confirm presence under a ready_check gate. */
|
|
186
|
+
accept(): void;
|
|
136
187
|
/** Close the socket and stop any reconnection. */
|
|
137
188
|
close(): void;
|
|
138
189
|
private dial;
|
|
@@ -153,4 +204,4 @@ declare function fenTurn(fen: string): Color;
|
|
|
153
204
|
* reaching the last rank — the UI appends a promotion piece (default queen). */
|
|
154
205
|
declare function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean;
|
|
155
206
|
|
|
156
|
-
export { type ClientMessage, type Color, type ConnectMode, EngineClient, type EngineClientEvents, type EngineClientOptions, type ErrorMessage, FILES, type GameOverMessage, type GameResult, type GameStateMessage, type GameStatus, type MoveMessage, type PieceChar, type PingMessage, type Seat, type ServerMessage, fenTurn, isPromotion, parseBoard };
|
|
207
|
+
export { type ClientMessage, type Color, type ConnectMode, type DrawDeclinedMessage, type DrawOfferMessage, EngineClient, type EngineClientEvents, type EngineClientOptions, type ErrorMessage, FILES, type GameOverMessage, type GameResult, type GameStateMessage, type GameStatus, type MoveMessage, type PieceChar, type PingMessage, type Seat, type ServerMessage, fenTurn, isPromotion, parseBoard };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
type Color = "white" | "black";
|
|
2
2
|
type Seat = Color | "spectator";
|
|
3
3
|
type GameStatus = "active" | "finished";
|
|
4
|
-
type GameResult = "white_win" | "black_win" | "draw";
|
|
4
|
+
type GameResult = "white_win" | "black_win" | "draw" | "aborted";
|
|
5
5
|
/** Full snapshot sent on join / rejoin / spectate. */
|
|
6
6
|
interface GameStateMessage {
|
|
7
7
|
type: "game_state";
|
|
@@ -41,7 +41,16 @@ interface PingMessage {
|
|
|
41
41
|
type: "ping";
|
|
42
42
|
t: number;
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
/** Sent to the opponent when a player offers a draw. */
|
|
45
|
+
interface DrawOfferMessage {
|
|
46
|
+
type: "draw_offer";
|
|
47
|
+
by: Color;
|
|
48
|
+
}
|
|
49
|
+
/** Sent to the offerer when the opponent declines their draw offer. */
|
|
50
|
+
interface DrawDeclinedMessage {
|
|
51
|
+
type: "draw_declined";
|
|
52
|
+
}
|
|
53
|
+
type ServerMessage = GameStateMessage | MoveMessage | GameOverMessage | ErrorMessage | PingMessage | DrawOfferMessage | DrawDeclinedMessage;
|
|
45
54
|
interface MoveIntent {
|
|
46
55
|
type: "move";
|
|
47
56
|
uci: string;
|
|
@@ -53,7 +62,31 @@ interface PongIntent {
|
|
|
53
62
|
type: "pong";
|
|
54
63
|
t: number;
|
|
55
64
|
}
|
|
56
|
-
|
|
65
|
+
/** Propose a draw to the opponent (rate-limited by the engine). */
|
|
66
|
+
interface OfferDrawIntent {
|
|
67
|
+
type: "offer_draw";
|
|
68
|
+
}
|
|
69
|
+
/** Accept the opponent's pending draw offer. */
|
|
70
|
+
interface AcceptDrawIntent {
|
|
71
|
+
type: "accept_draw";
|
|
72
|
+
}
|
|
73
|
+
/** Decline the opponent's pending draw offer. */
|
|
74
|
+
interface DeclineDrawIntent {
|
|
75
|
+
type: "decline_draw";
|
|
76
|
+
}
|
|
77
|
+
/** Claim a threefold-repetition / 50-move draw when eligible. */
|
|
78
|
+
interface ClaimDrawIntent {
|
|
79
|
+
type: "claim_draw";
|
|
80
|
+
}
|
|
81
|
+
/** Abort the game before both sides have made their first move. */
|
|
82
|
+
interface AbortIntent {
|
|
83
|
+
type: "abort";
|
|
84
|
+
}
|
|
85
|
+
/** Confirm presence under a ready_check gate. */
|
|
86
|
+
interface AcceptIntent {
|
|
87
|
+
type: "accept";
|
|
88
|
+
}
|
|
89
|
+
type ClientMessage = MoveIntent | ResignIntent | PongIntent | OfferDrawIntent | AcceptDrawIntent | DeclineDrawIntent | ClaimDrawIntent | AbortIntent | AcceptIntent;
|
|
57
90
|
|
|
58
91
|
/** How to authenticate the socket to a room. */
|
|
59
92
|
type ConnectMode = {
|
|
@@ -78,6 +111,8 @@ interface EngineClientEvents {
|
|
|
78
111
|
move: MoveMessage;
|
|
79
112
|
gameOver: GameOverMessage;
|
|
80
113
|
error: ErrorMessage;
|
|
114
|
+
drawOffer: DrawOfferMessage;
|
|
115
|
+
drawDeclined: DrawDeclinedMessage;
|
|
81
116
|
message: ServerMessage;
|
|
82
117
|
}
|
|
83
118
|
interface EngineClientOptions {
|
|
@@ -89,6 +124,8 @@ interface EngineClientOptions {
|
|
|
89
124
|
autoReconnect?: boolean;
|
|
90
125
|
reconnectDelayMs?: number;
|
|
91
126
|
maxReconnectDelayMs?: number;
|
|
127
|
+
/** Give up reconnecting after this many consecutive failed attempts (default 10). */
|
|
128
|
+
maxReconnectAttempts?: number;
|
|
92
129
|
}
|
|
93
130
|
type Listener<T> = (payload: T) => void;
|
|
94
131
|
/**
|
|
@@ -112,9 +149,11 @@ declare class EngineClient {
|
|
|
112
149
|
private readonly autoReconnect;
|
|
113
150
|
private readonly baseDelay;
|
|
114
151
|
private readonly maxDelay;
|
|
152
|
+
private readonly maxAttempts;
|
|
115
153
|
private ws;
|
|
116
154
|
private mode;
|
|
117
155
|
private closedByUser;
|
|
156
|
+
private finished;
|
|
118
157
|
private attempts;
|
|
119
158
|
private timer;
|
|
120
159
|
private _reconnectToken;
|
|
@@ -133,6 +172,18 @@ declare class EngineClient {
|
|
|
133
172
|
move(uci: string): void;
|
|
134
173
|
/** Resign the game. */
|
|
135
174
|
resign(): void;
|
|
175
|
+
/** Offer a draw to the opponent (rate-limited by the engine). */
|
|
176
|
+
offerDraw(): void;
|
|
177
|
+
/** Accept the opponent's pending draw offer. */
|
|
178
|
+
acceptDraw(): void;
|
|
179
|
+
/** Decline the opponent's pending draw offer. */
|
|
180
|
+
declineDraw(): void;
|
|
181
|
+
/** Claim a threefold-repetition / 50-move draw when eligible. */
|
|
182
|
+
claimDraw(): void;
|
|
183
|
+
/** Abort the game before both sides have made their first move. */
|
|
184
|
+
abort(): void;
|
|
185
|
+
/** Confirm presence under a ready_check gate. */
|
|
186
|
+
accept(): void;
|
|
136
187
|
/** Close the socket and stop any reconnection. */
|
|
137
188
|
close(): void;
|
|
138
189
|
private dial;
|
|
@@ -153,4 +204,4 @@ declare function fenTurn(fen: string): Color;
|
|
|
153
204
|
* reaching the last rank — the UI appends a promotion piece (default queen). */
|
|
154
205
|
declare function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean;
|
|
155
206
|
|
|
156
|
-
export { type ClientMessage, type Color, type ConnectMode, EngineClient, type EngineClientEvents, type EngineClientOptions, type ErrorMessage, FILES, type GameOverMessage, type GameResult, type GameStateMessage, type GameStatus, type MoveMessage, type PieceChar, type PingMessage, type Seat, type ServerMessage, fenTurn, isPromotion, parseBoard };
|
|
207
|
+
export { type ClientMessage, type Color, type ConnectMode, type DrawDeclinedMessage, type DrawOfferMessage, EngineClient, type EngineClientEvents, type EngineClientOptions, type ErrorMessage, FILES, type GameOverMessage, type GameResult, type GameStateMessage, type GameStatus, type MoveMessage, type PieceChar, type PingMessage, type Seat, type ServerMessage, fenTurn, isPromotion, parseBoard };
|
package/dist/index.js
CHANGED
|
@@ -6,9 +6,12 @@ var EngineClient = class {
|
|
|
6
6
|
autoReconnect;
|
|
7
7
|
baseDelay;
|
|
8
8
|
maxDelay;
|
|
9
|
+
maxAttempts;
|
|
9
10
|
ws = null;
|
|
10
11
|
mode = null;
|
|
11
12
|
closedByUser = false;
|
|
13
|
+
finished = false;
|
|
14
|
+
// game_over seen → terminal, no auto-reconnect
|
|
12
15
|
attempts = 0;
|
|
13
16
|
timer = null;
|
|
14
17
|
_reconnectToken = null;
|
|
@@ -22,6 +25,7 @@ var EngineClient = class {
|
|
|
22
25
|
this.autoReconnect = opts.autoReconnect ?? true;
|
|
23
26
|
this.baseDelay = opts.reconnectDelayMs ?? 500;
|
|
24
27
|
this.maxDelay = opts.maxReconnectDelayMs ?? 8e3;
|
|
28
|
+
this.maxAttempts = opts.maxReconnectAttempts ?? 10;
|
|
25
29
|
}
|
|
26
30
|
/** The current rotating reconnect token, or null (spectators never get one). */
|
|
27
31
|
get reconnectToken() {
|
|
@@ -46,7 +50,9 @@ var EngineClient = class {
|
|
|
46
50
|
}
|
|
47
51
|
/** Open the socket with the given credentials. */
|
|
48
52
|
connect(mode) {
|
|
53
|
+
if (this.timer) clearTimeout(this.timer);
|
|
49
54
|
this.closedByUser = false;
|
|
55
|
+
this.finished = false;
|
|
50
56
|
this.mode = mode;
|
|
51
57
|
this.attempts = 0;
|
|
52
58
|
this.dial();
|
|
@@ -59,6 +65,30 @@ var EngineClient = class {
|
|
|
59
65
|
resign() {
|
|
60
66
|
this.send({ type: "resign" });
|
|
61
67
|
}
|
|
68
|
+
/** Offer a draw to the opponent (rate-limited by the engine). */
|
|
69
|
+
offerDraw() {
|
|
70
|
+
this.send({ type: "offer_draw" });
|
|
71
|
+
}
|
|
72
|
+
/** Accept the opponent's pending draw offer. */
|
|
73
|
+
acceptDraw() {
|
|
74
|
+
this.send({ type: "accept_draw" });
|
|
75
|
+
}
|
|
76
|
+
/** Decline the opponent's pending draw offer. */
|
|
77
|
+
declineDraw() {
|
|
78
|
+
this.send({ type: "decline_draw" });
|
|
79
|
+
}
|
|
80
|
+
/** Claim a threefold-repetition / 50-move draw when eligible. */
|
|
81
|
+
claimDraw() {
|
|
82
|
+
this.send({ type: "claim_draw" });
|
|
83
|
+
}
|
|
84
|
+
/** Abort the game before both sides have made their first move. */
|
|
85
|
+
abort() {
|
|
86
|
+
this.send({ type: "abort" });
|
|
87
|
+
}
|
|
88
|
+
/** Confirm presence under a ready_check gate. */
|
|
89
|
+
accept() {
|
|
90
|
+
this.send({ type: "accept" });
|
|
91
|
+
}
|
|
62
92
|
/** Close the socket and stop any reconnection. */
|
|
63
93
|
close() {
|
|
64
94
|
this.closedByUser = true;
|
|
@@ -68,10 +98,16 @@ var EngineClient = class {
|
|
|
68
98
|
}
|
|
69
99
|
dial() {
|
|
70
100
|
if (!this.mode) return;
|
|
101
|
+
if (this.ws) {
|
|
102
|
+
this.ws.onopen = this.ws.onmessage = this.ws.onclose = this.ws.onerror = null;
|
|
103
|
+
try {
|
|
104
|
+
this.ws.close(1e3, "");
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
71
108
|
const ws = new this.WS(this.url + "?" + queryFor(this.mode));
|
|
72
109
|
this.ws = ws;
|
|
73
110
|
ws.onopen = () => {
|
|
74
|
-
this.attempts = 0;
|
|
75
111
|
this.emit("open", void 0);
|
|
76
112
|
};
|
|
77
113
|
ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
@@ -86,9 +122,11 @@ var EngineClient = class {
|
|
|
86
122
|
} catch {
|
|
87
123
|
return;
|
|
88
124
|
}
|
|
125
|
+
if (!msg || typeof msg.type !== "string") return;
|
|
89
126
|
this.emit("message", msg);
|
|
90
127
|
switch (msg.type) {
|
|
91
128
|
case "game_state":
|
|
129
|
+
this.attempts = 0;
|
|
92
130
|
if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;
|
|
93
131
|
this._seat = msg.you;
|
|
94
132
|
this.emit("gameState", msg);
|
|
@@ -97,11 +135,18 @@ var EngineClient = class {
|
|
|
97
135
|
this.emit("move", msg);
|
|
98
136
|
break;
|
|
99
137
|
case "game_over":
|
|
138
|
+
this.finished = true;
|
|
100
139
|
this.emit("gameOver", msg);
|
|
101
140
|
break;
|
|
102
141
|
case "error":
|
|
103
142
|
this.emit("error", msg);
|
|
104
143
|
break;
|
|
144
|
+
case "draw_offer":
|
|
145
|
+
this.emit("drawOffer", msg);
|
|
146
|
+
break;
|
|
147
|
+
case "draw_declined":
|
|
148
|
+
this.emit("drawDeclined", msg);
|
|
149
|
+
break;
|
|
105
150
|
case "ping":
|
|
106
151
|
this.send({ type: "pong", t: msg.t });
|
|
107
152
|
break;
|
|
@@ -109,16 +154,21 @@ var EngineClient = class {
|
|
|
109
154
|
}
|
|
110
155
|
handleClose(code, reason) {
|
|
111
156
|
this.ws = null;
|
|
112
|
-
const willReconnect = !this.closedByUser && this.autoReconnect && this._reconnectToken !== null;
|
|
157
|
+
const willReconnect = !this.closedByUser && !this.finished && this.autoReconnect && this._reconnectToken !== null;
|
|
113
158
|
this.emit("close", { code, reason, willReconnect });
|
|
114
159
|
if (willReconnect) this.scheduleReconnect();
|
|
115
160
|
}
|
|
116
161
|
scheduleReconnect() {
|
|
117
162
|
this.attempts += 1;
|
|
163
|
+
if (this.attempts > this.maxAttempts) {
|
|
164
|
+
this._reconnectToken = null;
|
|
165
|
+
this.emit("close", { code: 1006, reason: "reconnect gave up", willReconnect: false });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
118
168
|
const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);
|
|
119
169
|
this.emit("reconnecting", { attempt: this.attempts });
|
|
120
170
|
this.timer = setTimeout(() => {
|
|
121
|
-
if (this.closedByUser || this._reconnectToken === null) return;
|
|
171
|
+
if (this.closedByUser || this.finished || this._reconnectToken === null) return;
|
|
122
172
|
this.mode = { reconnect: this._reconnectToken };
|
|
123
173
|
this.dial();
|
|
124
174
|
}, delay);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/client.ts","../src/fen.ts"],"sourcesContent":["import type {\n ServerMessage,\n GameStateMessage,\n MoveMessage,\n GameOverMessage,\n ErrorMessage,\n Seat,\n} from \"./protocol.js\";\n\n/** How to authenticate the socket to a room. */\nexport type ConnectMode =\n | { ticket: string }\n | { reconnect: string }\n | { spectate: string };\n\n/** Events emitted by EngineClient. */\nexport interface EngineClientEvents {\n open: void;\n close: { code: number; reason: string; willReconnect: boolean };\n reconnecting: { attempt: number };\n gameState: GameStateMessage;\n move: MoveMessage;\n gameOver: GameOverMessage;\n error: ErrorMessage;\n message: ServerMessage;\n}\n\nexport interface EngineClientOptions {\n /** Base socket URL, e.g. \"wss://engine.example.com/ws\". */\n url: string;\n /** WebSocket implementation; defaults to the global (browser). Pass `ws` in Node. */\n WebSocket?: unknown;\n /** Auto-reconnect with the rotating token after an unexpected drop (default true). */\n autoReconnect?: boolean;\n reconnectDelayMs?: number;\n maxReconnectDelayMs?: number;\n}\n\ntype Listener<T> = (payload: T) => void;\ntype WSCtor = new (url: string) => WebSocketLike;\ninterface WebSocketLike {\n readyState: number;\n send(data: string): void;\n close(code?: number, reason?: string): void;\n onopen: ((ev: unknown) => void) | null;\n onmessage: ((ev: { data: unknown }) => void) | null;\n onclose: ((ev: { code: number; reason: string }) => void) | null;\n onerror: ((ev: unknown) => void) | null;\n}\n\nconst OPEN = 1;\n\n/**\n * EngineClient wraps one game socket to the inachess-engine. It handles the wire\n * protocol end to end: parses server messages, auto-answers latency pings, tracks\n * the rotating reconnect token, and (by default) transparently reconnects after a\n * drop. Framework-agnostic — the platform routes a player to the play view with a\n * ticket, then hands that ticket to this client.\n *\n * ```ts\n * const c = new EngineClient({ url: \"wss://engine/ws\" });\n * c.on(\"gameState\", s => render(s));\n * c.on(\"move\", m => render(m));\n * c.connect({ ticket });\n * c.move(\"e2e4\");\n * ```\n */\nexport class EngineClient {\n private readonly url: string;\n private readonly WS: WSCtor;\n private readonly autoReconnect: boolean;\n private readonly baseDelay: number;\n private readonly maxDelay: number;\n\n private ws: WebSocketLike | null = null;\n private mode: ConnectMode | null = null;\n private closedByUser = false;\n private attempts = 0;\n private timer: ReturnType<typeof setTimeout> | null = null;\n\n private _reconnectToken: string | null = null;\n private _seat: Seat | null = null;\n private listeners = new Map<keyof EngineClientEvents, Set<Listener<unknown>>>();\n\n constructor(opts: EngineClientOptions) {\n this.url = opts.url;\n const WS = (opts.WebSocket ?? (globalThis as { WebSocket?: unknown }).WebSocket) as WSCtor | undefined;\n if (!WS) throw new Error(\"no WebSocket implementation available; pass options.WebSocket\");\n this.WS = WS;\n this.autoReconnect = opts.autoReconnect ?? true;\n this.baseDelay = opts.reconnectDelayMs ?? 500;\n this.maxDelay = opts.maxReconnectDelayMs ?? 8000;\n }\n\n /** The current rotating reconnect token, or null (spectators never get one). */\n get reconnectToken(): string | null {\n return this._reconnectToken;\n }\n\n /** The recipient's seat: \"white\" | \"black\" | \"spectator\" | null (before join). */\n get seat(): Seat | null {\n return this._seat;\n }\n\n on<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n let set = this.listeners.get(event);\n if (!set) {\n set = new Set();\n this.listeners.set(event, set);\n }\n set.add(handler as Listener<unknown>);\n return this;\n }\n\n off<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n this.listeners.get(event)?.delete(handler as Listener<unknown>);\n return this;\n }\n\n /** Open the socket with the given credentials. */\n connect(mode: ConnectMode): void {\n this.closedByUser = false;\n this.mode = mode;\n this.attempts = 0;\n this.dial();\n }\n\n /** Submit a move (UCI). No-op if the socket is not open. */\n move(uci: string): void {\n this.send({ type: \"move\", uci });\n }\n\n /** Resign the game. */\n resign(): void {\n this.send({ type: \"resign\" });\n }\n\n /** Close the socket and stop any reconnection. */\n close(): void {\n this.closedByUser = true;\n if (this.timer) clearTimeout(this.timer);\n this.ws?.close(1000, \"\");\n this.ws = null;\n }\n\n private dial(): void {\n if (!this.mode) return;\n const ws = new this.WS(this.url + \"?\" + queryFor(this.mode));\n this.ws = ws;\n ws.onopen = () => {\n this.attempts = 0;\n this.emit(\"open\", undefined);\n };\n ws.onmessage = (ev) => this.handleMessage(ev.data);\n ws.onclose = (ev) => this.handleClose(ev.code, ev.reason);\n ws.onerror = () => {}; // a close event always follows\n }\n\n private handleMessage(data: unknown): void {\n let msg: ServerMessage;\n try {\n msg = JSON.parse(typeof data === \"string\" ? data : String(data)) as ServerMessage;\n } catch {\n return;\n }\n this.emit(\"message\", msg);\n switch (msg.type) {\n case \"game_state\":\n if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;\n this._seat = msg.you;\n this.emit(\"gameState\", msg);\n break;\n case \"move\":\n this.emit(\"move\", msg);\n break;\n case \"game_over\":\n this.emit(\"gameOver\", msg);\n break;\n case \"error\":\n this.emit(\"error\", msg);\n break;\n case \"ping\":\n this.send({ type: \"pong\", t: msg.t });\n break;\n }\n }\n\n private handleClose(code: number, reason: string): void {\n this.ws = null;\n const willReconnect = !this.closedByUser && this.autoReconnect && this._reconnectToken !== null;\n this.emit(\"close\", { code, reason, willReconnect });\n if (willReconnect) this.scheduleReconnect();\n }\n\n private scheduleReconnect(): void {\n this.attempts += 1;\n const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);\n this.emit(\"reconnecting\", { attempt: this.attempts });\n this.timer = setTimeout(() => {\n if (this.closedByUser || this._reconnectToken === null) return;\n this.mode = { reconnect: this._reconnectToken };\n this.dial();\n }, delay);\n }\n\n private send(obj: unknown): void {\n if (this.ws && this.ws.readyState === OPEN) this.ws.send(JSON.stringify(obj));\n }\n\n private emit<K extends keyof EngineClientEvents>(event: K, payload: EngineClientEvents[K]): void {\n this.listeners.get(event)?.forEach((h) => h(payload));\n }\n}\n\nfunction queryFor(mode: ConnectMode): string {\n if (\"ticket\" in mode) return \"ticket=\" + encodeURIComponent(mode.ticket);\n if (\"reconnect\" in mode) return \"reconnect=\" + encodeURIComponent(mode.reconnect);\n return \"spectate=\" + encodeURIComponent(mode.spectate);\n}\n","import type { Color } from \"./protocol.js\";\n\nexport type PieceChar = \"K\" | \"Q\" | \"R\" | \"B\" | \"N\" | \"P\" | \"k\" | \"q\" | \"r\" | \"b\" | \"n\" | \"p\";\n\nexport const FILES = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\", \"h\"] as const;\n\n/** parseBoard turns a FEN into a map of square (\"e4\") → piece char. */\nexport function parseBoard(fen: string): Record<string, PieceChar> {\n const rows = fen.split(\" \")[0].split(\"/\");\n const out: Record<string, PieceChar> = {};\n for (let i = 0; i < 8; i++) {\n const rank = 8 - i;\n let file = 0;\n for (const ch of rows[i] ?? \"\") {\n if (ch >= \"1\" && ch <= \"8\") {\n file += Number(ch);\n } else {\n out[FILES[file] + rank] = ch as PieceChar;\n file += 1;\n }\n }\n }\n return out;\n}\n\n/** fenTurn returns the side to move encoded in a FEN. */\nexport function fenTurn(fen: string): Color {\n return fen.split(\" \")[1] === \"b\" ? \"black\" : \"white\";\n}\n\n/** isPromotion reports whether moving the piece on `from` to `to` is a pawn\n * reaching the last rank — the UI appends a promotion piece (default queen). */\nexport function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean {\n const p = board[from];\n return (p === \"P\" && to[1] === \"8\") || (p === \"p\" && to[1] === \"1\");\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/client.ts","../src/fen.ts"],"sourcesContent":["import type {\n ServerMessage,\n GameStateMessage,\n MoveMessage,\n GameOverMessage,\n ErrorMessage,\n DrawOfferMessage,\n DrawDeclinedMessage,\n Seat,\n} from \"./protocol.js\";\n\n/** How to authenticate the socket to a room. */\nexport type ConnectMode =\n | { ticket: string }\n | { reconnect: string }\n | { spectate: string };\n\n/** Events emitted by EngineClient. */\nexport interface EngineClientEvents {\n open: void;\n close: { code: number; reason: string; willReconnect: boolean };\n reconnecting: { attempt: number };\n gameState: GameStateMessage;\n move: MoveMessage;\n gameOver: GameOverMessage;\n error: ErrorMessage;\n drawOffer: DrawOfferMessage;\n drawDeclined: DrawDeclinedMessage;\n message: ServerMessage;\n}\n\nexport interface EngineClientOptions {\n /** Base socket URL, e.g. \"wss://engine.example.com/ws\". */\n url: string;\n /** WebSocket implementation; defaults to the global (browser). Pass `ws` in Node. */\n WebSocket?: unknown;\n /** Auto-reconnect with the rotating token after an unexpected drop (default true). */\n autoReconnect?: boolean;\n reconnectDelayMs?: number;\n maxReconnectDelayMs?: number;\n /** Give up reconnecting after this many consecutive failed attempts (default 10). */\n maxReconnectAttempts?: number;\n}\n\ntype Listener<T> = (payload: T) => void;\ntype WSCtor = new (url: string) => WebSocketLike;\ninterface WebSocketLike {\n readyState: number;\n send(data: string): void;\n close(code?: number, reason?: string): void;\n onopen: ((ev: unknown) => void) | null;\n onmessage: ((ev: { data: unknown }) => void) | null;\n onclose: ((ev: { code: number; reason: string }) => void) | null;\n onerror: ((ev: unknown) => void) | null;\n}\n\nconst OPEN = 1;\n\n/**\n * EngineClient wraps one game socket to the inachess-engine. It handles the wire\n * protocol end to end: parses server messages, auto-answers latency pings, tracks\n * the rotating reconnect token, and (by default) transparently reconnects after a\n * drop. Framework-agnostic — the platform routes a player to the play view with a\n * ticket, then hands that ticket to this client.\n *\n * ```ts\n * const c = new EngineClient({ url: \"wss://engine/ws\" });\n * c.on(\"gameState\", s => render(s));\n * c.on(\"move\", m => render(m));\n * c.connect({ ticket });\n * c.move(\"e2e4\");\n * ```\n */\nexport class EngineClient {\n private readonly url: string;\n private readonly WS: WSCtor;\n private readonly autoReconnect: boolean;\n private readonly baseDelay: number;\n private readonly maxDelay: number;\n\n private readonly maxAttempts: number;\n\n private ws: WebSocketLike | null = null;\n private mode: ConnectMode | null = null;\n private closedByUser = false;\n private finished = false; // game_over seen → terminal, no auto-reconnect\n private attempts = 0;\n private timer: ReturnType<typeof setTimeout> | null = null;\n\n private _reconnectToken: string | null = null;\n private _seat: Seat | null = null;\n private listeners = new Map<keyof EngineClientEvents, Set<Listener<unknown>>>();\n\n constructor(opts: EngineClientOptions) {\n this.url = opts.url;\n const WS = (opts.WebSocket ?? (globalThis as { WebSocket?: unknown }).WebSocket) as WSCtor | undefined;\n if (!WS) throw new Error(\"no WebSocket implementation available; pass options.WebSocket\");\n this.WS = WS;\n this.autoReconnect = opts.autoReconnect ?? true;\n this.baseDelay = opts.reconnectDelayMs ?? 500;\n this.maxDelay = opts.maxReconnectDelayMs ?? 8000;\n this.maxAttempts = opts.maxReconnectAttempts ?? 10;\n }\n\n /** The current rotating reconnect token, or null (spectators never get one). */\n get reconnectToken(): string | null {\n return this._reconnectToken;\n }\n\n /** The recipient's seat: \"white\" | \"black\" | \"spectator\" | null (before join). */\n get seat(): Seat | null {\n return this._seat;\n }\n\n on<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n let set = this.listeners.get(event);\n if (!set) {\n set = new Set();\n this.listeners.set(event, set);\n }\n set.add(handler as Listener<unknown>);\n return this;\n }\n\n off<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n this.listeners.get(event)?.delete(handler as Listener<unknown>);\n return this;\n }\n\n /** Open the socket with the given credentials. */\n connect(mode: ConnectMode): void {\n if (this.timer) clearTimeout(this.timer); // cancel a pending reconnect so it can't clobber this socket\n this.closedByUser = false;\n this.finished = false;\n this.mode = mode;\n this.attempts = 0;\n this.dial();\n }\n\n /** Submit a move (UCI). No-op if the socket is not open. */\n move(uci: string): void {\n this.send({ type: \"move\", uci });\n }\n\n /** Resign the game. */\n resign(): void {\n this.send({ type: \"resign\" });\n }\n\n /** Offer a draw to the opponent (rate-limited by the engine). */\n offerDraw(): void {\n this.send({ type: \"offer_draw\" });\n }\n\n /** Accept the opponent's pending draw offer. */\n acceptDraw(): void {\n this.send({ type: \"accept_draw\" });\n }\n\n /** Decline the opponent's pending draw offer. */\n declineDraw(): void {\n this.send({ type: \"decline_draw\" });\n }\n\n /** Claim a threefold-repetition / 50-move draw when eligible. */\n claimDraw(): void {\n this.send({ type: \"claim_draw\" });\n }\n\n /** Abort the game before both sides have made their first move. */\n abort(): void {\n this.send({ type: \"abort\" });\n }\n\n /** Confirm presence under a ready_check gate. */\n accept(): void {\n this.send({ type: \"accept\" });\n }\n\n /** Close the socket and stop any reconnection. */\n close(): void {\n this.closedByUser = true;\n if (this.timer) clearTimeout(this.timer);\n this.ws?.close(1000, \"\");\n this.ws = null;\n }\n\n private dial(): void {\n if (!this.mode) return;\n if (this.ws) {\n // Detach + close any previous socket so its late events can't drive this\n // client or spawn a second reconnect chain.\n this.ws.onopen = this.ws.onmessage = this.ws.onclose = this.ws.onerror = null;\n try {\n this.ws.close(1000, \"\");\n } catch {\n /* closing a still-CONNECTING socket can throw; ignore */\n }\n }\n const ws = new this.WS(this.url + \"?\" + queryFor(this.mode));\n this.ws = ws;\n ws.onopen = () => {\n // Do NOT reset attempts here — a rejected reconnect still fires onopen\n // before the server's policy close. Reset only on a real join (game_state).\n this.emit(\"open\", undefined);\n };\n ws.onmessage = (ev) => this.handleMessage(ev.data);\n ws.onclose = (ev) => this.handleClose(ev.code, ev.reason);\n ws.onerror = () => {}; // a close event always follows\n }\n\n private handleMessage(data: unknown): void {\n let msg: ServerMessage;\n try {\n msg = JSON.parse(typeof data === \"string\" ? data : String(data)) as ServerMessage;\n } catch {\n return;\n }\n if (!msg || typeof (msg as { type?: unknown }).type !== \"string\") return;\n this.emit(\"message\", msg);\n switch (msg.type) {\n case \"game_state\":\n this.attempts = 0; // a real join succeeded → reset backoff\n if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;\n this._seat = msg.you;\n this.emit(\"gameState\", msg);\n break;\n case \"move\":\n this.emit(\"move\", msg);\n break;\n case \"game_over\":\n this.finished = true; // terminal — a later socket close must not reconnect\n this.emit(\"gameOver\", msg);\n break;\n case \"error\":\n this.emit(\"error\", msg);\n break;\n case \"draw_offer\":\n this.emit(\"drawOffer\", msg);\n break;\n case \"draw_declined\":\n this.emit(\"drawDeclined\", msg);\n break;\n case \"ping\":\n this.send({ type: \"pong\", t: msg.t });\n break;\n }\n }\n\n private handleClose(code: number, reason: string): void {\n this.ws = null;\n const willReconnect =\n !this.closedByUser && !this.finished && this.autoReconnect && this._reconnectToken !== null;\n this.emit(\"close\", { code, reason, willReconnect });\n if (willReconnect) this.scheduleReconnect();\n }\n\n private scheduleReconnect(): void {\n this.attempts += 1;\n if (this.attempts > this.maxAttempts) {\n // A consumed/invalid token would otherwise retry forever. Give up terminally.\n this._reconnectToken = null;\n this.emit(\"close\", { code: 1006, reason: \"reconnect gave up\", willReconnect: false });\n return;\n }\n const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);\n this.emit(\"reconnecting\", { attempt: this.attempts });\n this.timer = setTimeout(() => {\n if (this.closedByUser || this.finished || this._reconnectToken === null) return;\n this.mode = { reconnect: this._reconnectToken };\n this.dial();\n }, delay);\n }\n\n private send(obj: unknown): void {\n if (this.ws && this.ws.readyState === OPEN) this.ws.send(JSON.stringify(obj));\n }\n\n private emit<K extends keyof EngineClientEvents>(event: K, payload: EngineClientEvents[K]): void {\n this.listeners.get(event)?.forEach((h) => h(payload));\n }\n}\n\nfunction queryFor(mode: ConnectMode): string {\n if (\"ticket\" in mode) return \"ticket=\" + encodeURIComponent(mode.ticket);\n if (\"reconnect\" in mode) return \"reconnect=\" + encodeURIComponent(mode.reconnect);\n return \"spectate=\" + encodeURIComponent(mode.spectate);\n}\n","import type { Color } from \"./protocol.js\";\n\nexport type PieceChar = \"K\" | \"Q\" | \"R\" | \"B\" | \"N\" | \"P\" | \"k\" | \"q\" | \"r\" | \"b\" | \"n\" | \"p\";\n\nexport const FILES = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\", \"h\"] as const;\n\n/** parseBoard turns a FEN into a map of square (\"e4\") → piece char. */\nexport function parseBoard(fen: string): Record<string, PieceChar> {\n const rows = fen.split(\" \")[0].split(\"/\");\n const out: Record<string, PieceChar> = {};\n for (let i = 0; i < 8; i++) {\n const rank = 8 - i;\n let file = 0;\n for (const ch of rows[i] ?? \"\") {\n if (ch >= \"1\" && ch <= \"8\") {\n file += Number(ch);\n } else {\n out[FILES[file] + rank] = ch as PieceChar;\n file += 1;\n }\n }\n }\n return out;\n}\n\n/** fenTurn returns the side to move encoded in a FEN. */\nexport function fenTurn(fen: string): Color {\n return fen.split(\" \")[1] === \"b\" ? \"black\" : \"white\";\n}\n\n/** isPromotion reports whether moving the piece on `from` to `to` is a pawn\n * reaching the last rank — the UI appends a promotion piece (default queen). */\nexport function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean {\n const p = board[from];\n return (p === \"P\" && to[1] === \"8\") || (p === \"p\" && to[1] === \"1\");\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inachess/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "JavaScript/TypeScript client SDK for the inachess-engine socket protocol.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
"require": "./dist/index.cjs"
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
|
-
"files": [
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
17
19
|
"scripts": {
|
|
18
20
|
"build": "tsup",
|
|
19
21
|
"test": "node --test",
|
|
@@ -22,7 +24,12 @@
|
|
|
22
24
|
"publishConfig": {
|
|
23
25
|
"access": "public"
|
|
24
26
|
},
|
|
25
|
-
"keywords": [
|
|
27
|
+
"keywords": [
|
|
28
|
+
"chess",
|
|
29
|
+
"inachess",
|
|
30
|
+
"websocket",
|
|
31
|
+
"sdk"
|
|
32
|
+
],
|
|
26
33
|
"license": "MIT",
|
|
27
34
|
"devDependencies": {
|
|
28
35
|
"tsup": "^8.3.5",
|