@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 CHANGED
@@ -1,9 +1,16 @@
1
1
  # @inachess/sdk
2
2
 
3
- JavaScript/TypeScript client SDK for the **inachess-engine** WebSocket protocol.
4
- Framework-agnostic — runs in the browser and in Node. It owns the wire protocol
5
- (join, moves, latency pings, reconnect) so your app never hand-rolls socket
6
- messages.
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
- ## Usage
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"); // UCI everywhere
30
- client.resign();
36
+ client.move("e2e4");
31
37
  ```
32
38
 
33
- The client auto-answers the engine's latency pings and auto-reconnects with the
34
- engine's rotating reconnect token after a drop. Connect modes:
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
- Helpers for rendering are exported too: `parseBoard(fen)`, `fenTurn(fen)`,
43
- `isPromotion(board, from, to)`.
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);
@@ -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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACwDA,IAAM,OAAO;AAiBN,IAAM,eAAN,MAAmB;AAAA,EACP;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEA;AAAA,EAET,KAA2B;AAAA,EAC3B,OAA2B;AAAA,EAC3B,eAAe;AAAA,EACf,WAAW;AAAA;AAAA,EACX,WAAW;AAAA,EACX,QAA8C;AAAA,EAE9C,kBAAiC;AAAA,EACjC,QAAqB;AAAA,EACrB,YAAY,oBAAI,IAAsD;AAAA,EAE9E,YAAY,MAA2B;AACrC,SAAK,MAAM,KAAK;AAChB,UAAM,KAAM,KAAK,aAAc,WAAuC;AACtE,QAAI,CAAC,GAAI,OAAM,IAAI,MAAM,+DAA+D;AACxF,SAAK,KAAK;AACV,SAAK,gBAAgB,KAAK,iBAAiB;AAC3C,SAAK,YAAY,KAAK,oBAAoB;AAC1C,SAAK,WAAW,KAAK,uBAAuB;AAC5C,SAAK,cAAc,KAAK,wBAAwB;AAAA,EAClD;AAAA;AAAA,EAGA,IAAI,iBAAgC;AAClC,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,IAAI,OAAoB;AACtB,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,GAAuC,OAAU,SAAgD;AAC/F,QAAI,MAAM,KAAK,UAAU,IAAI,KAAK;AAClC,QAAI,CAAC,KAAK;AACR,YAAM,oBAAI,IAAI;AACd,WAAK,UAAU,IAAI,OAAO,GAAG;AAAA,IAC/B;AACA,QAAI,IAAI,OAA4B;AACpC,WAAO;AAAA,EACT;AAAA,EAEA,IAAwC,OAAU,SAAgD;AAChG,SAAK,UAAU,IAAI,KAAK,GAAG,OAAO,OAA4B;AAC9D,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,QAAQ,MAAyB;AAC/B,QAAI,KAAK,MAAO,cAAa,KAAK,KAAK;AACvC,SAAK,eAAe;AACpB,SAAK,WAAW;AAChB,SAAK,OAAO;AACZ,SAAK,WAAW;AAChB,SAAK,KAAK;AAAA,EACZ;AAAA;AAAA,EAGA,KAAK,KAAmB;AACtB,SAAK,KAAK,EAAE,MAAM,QAAQ,IAAI,CAAC;AAAA,EACjC;AAAA;AAAA,EAGA,SAAe;AACb,SAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAAA,EAC9B;AAAA;AAAA,EAGA,YAAkB;AAChB,SAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAAA,EAClC;AAAA;AAAA,EAGA,aAAmB;AACjB,SAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAAA,EACnC;AAAA;AAAA,EAGA,cAAoB;AAClB,SAAK,KAAK,EAAE,MAAM,eAAe,CAAC;AAAA,EACpC;AAAA;AAAA,EAGA,YAAkB;AAChB,SAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAAA,EAClC;AAAA;AAAA,EAGA,QAAc;AACZ,SAAK,KAAK,EAAE,MAAM,QAAQ,CAAC;AAAA,EAC7B;AAAA;AAAA,EAGA,SAAe;AACb,SAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAAA,EAC9B;AAAA;AAAA,EAGA,QAAc;AACZ,SAAK,eAAe;AACpB,QAAI,KAAK,MAAO,cAAa,KAAK,KAAK;AACvC,SAAK,IAAI,MAAM,KAAM,EAAE;AACvB,SAAK,KAAK;AAAA,EACZ;AAAA,EAEQ,OAAa;AACnB,QAAI,CAAC,KAAK,KAAM;AAChB,QAAI,KAAK,IAAI;AAGX,WAAK,GAAG,SAAS,KAAK,GAAG,YAAY,KAAK,GAAG,UAAU,KAAK,GAAG,UAAU;AACzE,UAAI;AACF,aAAK,GAAG,MAAM,KAAM,EAAE;AAAA,MACxB,QAAQ;AAAA,MAER;AAAA,IACF;AACA,UAAM,KAAK,IAAI,KAAK,GAAG,KAAK,MAAM,MAAM,SAAS,KAAK,IAAI,CAAC;AAC3D,SAAK,KAAK;AACV,OAAG,SAAS,MAAM;AAGhB,WAAK,KAAK,QAAQ,MAAS;AAAA,IAC7B;AACA,OAAG,YAAY,CAAC,OAAO,KAAK,cAAc,GAAG,IAAI;AACjD,OAAG,UAAU,CAAC,OAAO,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM;AACxD,OAAG,UAAU,MAAM;AAAA,IAAC;AAAA,EACtB;AAAA,EAEQ,cAAc,MAAqB;AACzC,QAAI;AACJ,QAAI;AACF,YAAM,KAAK,MAAM,OAAO,SAAS,WAAW,OAAO,OAAO,IAAI,CAAC;AAAA,IACjE,QAAQ;AACN;AAAA,IACF;AACA,QAAI,CAAC,OAAO,OAAQ,IAA2B,SAAS,SAAU;AAClE,SAAK,KAAK,WAAW,GAAG;AACxB,YAAQ,IAAI,MAAM;AAAA,MAChB,KAAK;AACH,aAAK,WAAW;AAChB,YAAI,IAAI,eAAgB,MAAK,kBAAkB,IAAI;AACnD,aAAK,QAAQ,IAAI;AACjB,aAAK,KAAK,aAAa,GAAG;AAC1B;AAAA,MACF,KAAK;AACH,aAAK,KAAK,QAAQ,GAAG;AACrB;AAAA,MACF,KAAK;AACH,aAAK,WAAW;AAChB,aAAK,KAAK,YAAY,GAAG;AACzB;AAAA,MACF,KAAK;AACH,aAAK,KAAK,SAAS,GAAG;AACtB;AAAA,MACF,KAAK;AACH,aAAK,KAAK,aAAa,GAAG;AAC1B;AAAA,MACF,KAAK;AACH,aAAK,KAAK,gBAAgB,GAAG;AAC7B;AAAA,MACF,KAAK;AACH,aAAK,KAAK,EAAE,MAAM,QAAQ,GAAG,IAAI,EAAE,CAAC;AACpC;AAAA,IACJ;AAAA,EACF;AAAA,EAEQ,YAAY,MAAc,QAAsB;AACtD,SAAK,KAAK;AACV,UAAM,gBACJ,CAAC,KAAK,gBAAgB,CAAC,KAAK,YAAY,KAAK,iBAAiB,KAAK,oBAAoB;AACzF,SAAK,KAAK,SAAS,EAAE,MAAM,QAAQ,cAAc,CAAC;AAClD,QAAI,cAAe,MAAK,kBAAkB;AAAA,EAC5C;AAAA,EAEQ,oBAA0B;AAChC,SAAK,YAAY;AACjB,QAAI,KAAK,WAAW,KAAK,aAAa;AAEpC,WAAK,kBAAkB;AACvB,WAAK,KAAK,SAAS,EAAE,MAAM,MAAM,QAAQ,qBAAqB,eAAe,MAAM,CAAC;AACpF;AAAA,IACF;AACA,UAAM,QAAQ,KAAK,IAAI,KAAK,YAAY,MAAM,KAAK,WAAW,IAAI,KAAK,QAAQ;AAC/E,SAAK,KAAK,gBAAgB,EAAE,SAAS,KAAK,SAAS,CAAC;AACpD,SAAK,QAAQ,WAAW,MAAM;AAC5B,UAAI,KAAK,gBAAgB,KAAK,YAAY,KAAK,oBAAoB,KAAM;AACzE,WAAK,OAAO,EAAE,WAAW,KAAK,gBAAgB;AAC9C,WAAK,KAAK;AAAA,IACZ,GAAG,KAAK;AAAA,EACV;AAAA,EAEQ,KAAK,KAAoB;AAC/B,QAAI,KAAK,MAAM,KAAK,GAAG,eAAe,KAAM,MAAK,GAAG,KAAK,KAAK,UAAU,GAAG,CAAC;AAAA,EAC9E;AAAA,EAEQ,KAAyC,OAAU,SAAsC;AAC/F,SAAK,UAAU,IAAI,KAAK,GAAG,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;AAAA,EACtD;AACF;AAEA,SAAS,SAAS,MAA2B;AAC3C,MAAI,YAAY,KAAM,QAAO,YAAY,mBAAmB,KAAK,MAAM;AACvE,MAAI,eAAe,KAAM,QAAO,eAAe,mBAAmB,KAAK,SAAS;AAChF,SAAO,cAAc,mBAAmB,KAAK,QAAQ;AACvD;;;AC3RO,IAAM,QAAQ,CAAC,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,GAAG;AAGrD,SAAS,WAAW,KAAwC;AACjE,QAAM,OAAO,IAAI,MAAM,GAAG,EAAE,CAAC,EAAE,MAAM,GAAG;AACxC,QAAM,MAAiC,CAAC;AACxC,WAAS,IAAI,GAAG,IAAI,GAAG,KAAK;AAC1B,UAAM,OAAO,IAAI;AACjB,QAAI,OAAO;AACX,eAAW,MAAM,KAAK,CAAC,KAAK,IAAI;AAC9B,UAAI,MAAM,OAAO,MAAM,KAAK;AAC1B,gBAAQ,OAAO,EAAE;AAAA,MACnB,OAAO;AACL,YAAI,MAAM,IAAI,IAAI,IAAI,IAAI;AAC1B,gBAAQ;AAAA,MACV;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,QAAQ,KAAoB;AAC1C,SAAO,IAAI,MAAM,GAAG,EAAE,CAAC,MAAM,MAAM,UAAU;AAC/C;AAIO,SAAS,YAAY,OAAkC,MAAc,IAAqB;AAC/F,QAAM,IAAI,MAAM,IAAI;AACpB,SAAQ,MAAM,OAAO,GAAG,CAAC,MAAM,OAAS,MAAM,OAAO,GAAG,CAAC,MAAM;AACjE;","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
- type ServerMessage = GameStateMessage | MoveMessage | GameOverMessage | ErrorMessage | PingMessage;
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
- type ClientMessage = MoveIntent | ResignIntent | PongIntent;
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
- type ServerMessage = GameStateMessage | MoveMessage | GameOverMessage | ErrorMessage | PingMessage;
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
- type ClientMessage = MoveIntent | ResignIntent | PongIntent;
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":";AAkDA,IAAM,OAAO;AAiBN,IAAM,eAAN,MAAmB;AAAA,EACP;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,KAA2B;AAAA,EAC3B,OAA2B;AAAA,EAC3B,eAAe;AAAA,EACf,WAAW;AAAA,EACX,QAA8C;AAAA,EAE9C,kBAAiC;AAAA,EACjC,QAAqB;AAAA,EACrB,YAAY,oBAAI,IAAsD;AAAA,EAE9E,YAAY,MAA2B;AACrC,SAAK,MAAM,KAAK;AAChB,UAAM,KAAM,KAAK,aAAc,WAAuC;AACtE,QAAI,CAAC,GAAI,OAAM,IAAI,MAAM,+DAA+D;AACxF,SAAK,KAAK;AACV,SAAK,gBAAgB,KAAK,iBAAiB;AAC3C,SAAK,YAAY,KAAK,oBAAoB;AAC1C,SAAK,WAAW,KAAK,uBAAuB;AAAA,EAC9C;AAAA;AAAA,EAGA,IAAI,iBAAgC;AAClC,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,IAAI,OAAoB;AACtB,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,GAAuC,OAAU,SAAgD;AAC/F,QAAI,MAAM,KAAK,UAAU,IAAI,KAAK;AAClC,QAAI,CAAC,KAAK;AACR,YAAM,oBAAI,IAAI;AACd,WAAK,UAAU,IAAI,OAAO,GAAG;AAAA,IAC/B;AACA,QAAI,IAAI,OAA4B;AACpC,WAAO;AAAA,EACT;AAAA,EAEA,IAAwC,OAAU,SAAgD;AAChG,SAAK,UAAU,IAAI,KAAK,GAAG,OAAO,OAA4B;AAC9D,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,QAAQ,MAAyB;AAC/B,SAAK,eAAe;AACpB,SAAK,OAAO;AACZ,SAAK,WAAW;AAChB,SAAK,KAAK;AAAA,EACZ;AAAA;AAAA,EAGA,KAAK,KAAmB;AACtB,SAAK,KAAK,EAAE,MAAM,QAAQ,IAAI,CAAC;AAAA,EACjC;AAAA;AAAA,EAGA,SAAe;AACb,SAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAAA,EAC9B;AAAA;AAAA,EAGA,QAAc;AACZ,SAAK,eAAe;AACpB,QAAI,KAAK,MAAO,cAAa,KAAK,KAAK;AACvC,SAAK,IAAI,MAAM,KAAM,EAAE;AACvB,SAAK,KAAK;AAAA,EACZ;AAAA,EAEQ,OAAa;AACnB,QAAI,CAAC,KAAK,KAAM;AAChB,UAAM,KAAK,IAAI,KAAK,GAAG,KAAK,MAAM,MAAM,SAAS,KAAK,IAAI,CAAC;AAC3D,SAAK,KAAK;AACV,OAAG,SAAS,MAAM;AAChB,WAAK,WAAW;AAChB,WAAK,KAAK,QAAQ,MAAS;AAAA,IAC7B;AACA,OAAG,YAAY,CAAC,OAAO,KAAK,cAAc,GAAG,IAAI;AACjD,OAAG,UAAU,CAAC,OAAO,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM;AACxD,OAAG,UAAU,MAAM;AAAA,IAAC;AAAA,EACtB;AAAA,EAEQ,cAAc,MAAqB;AACzC,QAAI;AACJ,QAAI;AACF,YAAM,KAAK,MAAM,OAAO,SAAS,WAAW,OAAO,OAAO,IAAI,CAAC;AAAA,IACjE,QAAQ;AACN;AAAA,IACF;AACA,SAAK,KAAK,WAAW,GAAG;AACxB,YAAQ,IAAI,MAAM;AAAA,MAChB,KAAK;AACH,YAAI,IAAI,eAAgB,MAAK,kBAAkB,IAAI;AACnD,aAAK,QAAQ,IAAI;AACjB,aAAK,KAAK,aAAa,GAAG;AAC1B;AAAA,MACF,KAAK;AACH,aAAK,KAAK,QAAQ,GAAG;AACrB;AAAA,MACF,KAAK;AACH,aAAK,KAAK,YAAY,GAAG;AACzB;AAAA,MACF,KAAK;AACH,aAAK,KAAK,SAAS,GAAG;AACtB;AAAA,MACF,KAAK;AACH,aAAK,KAAK,EAAE,MAAM,QAAQ,GAAG,IAAI,EAAE,CAAC;AACpC;AAAA,IACJ;AAAA,EACF;AAAA,EAEQ,YAAY,MAAc,QAAsB;AACtD,SAAK,KAAK;AACV,UAAM,gBAAgB,CAAC,KAAK,gBAAgB,KAAK,iBAAiB,KAAK,oBAAoB;AAC3F,SAAK,KAAK,SAAS,EAAE,MAAM,QAAQ,cAAc,CAAC;AAClD,QAAI,cAAe,MAAK,kBAAkB;AAAA,EAC5C;AAAA,EAEQ,oBAA0B;AAChC,SAAK,YAAY;AACjB,UAAM,QAAQ,KAAK,IAAI,KAAK,YAAY,MAAM,KAAK,WAAW,IAAI,KAAK,QAAQ;AAC/E,SAAK,KAAK,gBAAgB,EAAE,SAAS,KAAK,SAAS,CAAC;AACpD,SAAK,QAAQ,WAAW,MAAM;AAC5B,UAAI,KAAK,gBAAgB,KAAK,oBAAoB,KAAM;AACxD,WAAK,OAAO,EAAE,WAAW,KAAK,gBAAgB;AAC9C,WAAK,KAAK;AAAA,IACZ,GAAG,KAAK;AAAA,EACV;AAAA,EAEQ,KAAK,KAAoB;AAC/B,QAAI,KAAK,MAAM,KAAK,GAAG,eAAe,KAAM,MAAK,GAAG,KAAK,KAAK,UAAU,GAAG,CAAC;AAAA,EAC9E;AAAA,EAEQ,KAAyC,OAAU,SAAsC;AAC/F,SAAK,UAAU,IAAI,KAAK,GAAG,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;AAAA,EACtD;AACF;AAEA,SAAS,SAAS,MAA2B;AAC3C,MAAI,YAAY,KAAM,QAAO,YAAY,mBAAmB,KAAK,MAAM;AACvE,MAAI,eAAe,KAAM,QAAO,eAAe,mBAAmB,KAAK,SAAS;AAChF,SAAO,cAAc,mBAAmB,KAAK,QAAQ;AACvD;;;ACtNO,IAAM,QAAQ,CAAC,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,GAAG;AAGrD,SAAS,WAAW,KAAwC;AACjE,QAAM,OAAO,IAAI,MAAM,GAAG,EAAE,CAAC,EAAE,MAAM,GAAG;AACxC,QAAM,MAAiC,CAAC;AACxC,WAAS,IAAI,GAAG,IAAI,GAAG,KAAK;AAC1B,UAAM,OAAO,IAAI;AACjB,QAAI,OAAO;AACX,eAAW,MAAM,KAAK,CAAC,KAAK,IAAI;AAC9B,UAAI,MAAM,OAAO,MAAM,KAAK;AAC1B,gBAAQ,OAAO,EAAE;AAAA,MACnB,OAAO;AACL,YAAI,MAAM,IAAI,IAAI,IAAI,IAAI;AAC1B,gBAAQ;AAAA,MACV;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,QAAQ,KAAoB;AAC1C,SAAO,IAAI,MAAM,GAAG,EAAE,CAAC,MAAM,MAAM,UAAU;AAC/C;AAIO,SAAS,YAAY,OAAkC,MAAc,IAAqB;AAC/F,QAAM,IAAI,MAAM,IAAI;AACpB,SAAQ,MAAM,OAAO,GAAG,CAAC,MAAM,OAAS,MAAM,OAAO,GAAG,CAAC,MAAM;AACjE;","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.1.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": ["dist"],
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": ["chess", "inachess", "websocket", "sdk"],
27
+ "keywords": [
28
+ "chess",
29
+ "inachess",
30
+ "websocket",
31
+ "sdk"
32
+ ],
26
33
  "license": "MIT",
27
34
  "devDependencies": {
28
35
  "tsup": "^8.3.5",