From addb008785b131faddc1ab8f76cb1f8fbe65f99a Mon Sep 17 00:00:00 2001 From: Sporkinator Date: Fri, 9 Oct 2026 01:27:38 -0700 Subject: [PATCH 1/5] Added Socket.io client --- docs/stockfish-socket-contract.md | 0 package-lock.json | 65 +++++++++++++++++++ package.json | 1 + src/features/play/services/StockfishClient.ts | 0 4 files changed, 66 insertions(+) create mode 100644 docs/stockfish-socket-contract.md create mode 100644 src/features/play/services/StockfishClient.ts diff --git a/docs/stockfish-socket-contract.md b/docs/stockfish-socket-contract.md new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index cdf1736..1019f01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-worklets": "0.10.1", + "socket.io-client": "^4.8.4", "zod": "4.4.3" }, "devDependencies": { @@ -3884,6 +3885,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.102.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.0.tgz", @@ -6477,6 +6484,28 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -13936,6 +13965,34 @@ "node": ">=8.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz", + "integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -15343,6 +15400,14 @@ "dev": true, "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index fe7433d..7cddd5c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-worklets": "0.10.1", + "socket.io-client": "^4.8.4", "zod": "4.4.3" }, "devDependencies": { diff --git a/src/features/play/services/StockfishClient.ts b/src/features/play/services/StockfishClient.ts new file mode 100644 index 0000000..e69de29 From c4d80e89e06fe7e41568271dcc902067c522e1aa Mon Sep 17 00:00:00 2001 From: Sporkinator Date: Fri, 9 Oct 2026 02:12:27 -0700 Subject: [PATCH 2/5] Added EXPO_PUBLIC_STOCKFISH_SOCKET_URL to validated environment --- .env.example | 7 ++--- src/config/env.ts | 69 ++++++++++++++++++++++++++++++++--------------- test/env.test.ts | 39 +++++++++++++++++++++++++++ test/setup.ts | 1 + 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 7dc9752..68ab32b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ -# Public values only. Copy this file to .env.local for local development. -EXPO_PUBLIC_APP_ENV=development -EXPO_PUBLIC_API_BASE_URL=http://10.0.2.2:8000 +# Public values only. Copy this file to .env.local for local development. +EXPO_PUBLIC_APP_ENV=development +EXPO_PUBLIC_API_BASE_URL=http://10.0.2.2:8000 +EXPO_PUBLIC_STOCKFISH_SOCKET_URL=http://10.0.2.2:8080 \ No newline at end of file diff --git a/src/config/env.ts b/src/config/env.ts index 76d8381..78859ee 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,8 +1,11 @@ import { z } from 'zod'; +const serviceUrl = z.string().trim().min(1).url(); + const publicEnvironmentSchema = z.object({ appEnv: z.enum(['development', 'preview', 'production']), - apiBaseUrl: z.string().trim().min(1).url(), + apiBaseUrl: serviceUrl, + stockfishSocketUrl: serviceUrl, }); export type AppEnvironment = z.infer['appEnv']; @@ -10,6 +13,7 @@ export type AppEnvironment = z.infer['appEnv']; export interface PublicEnvironment { appEnv: AppEnvironment; apiBaseUrl: string; + stockfishSocketUrl: string; } export class EnvironmentConfigError extends Error { @@ -22,6 +26,7 @@ export class EnvironmentConfigError extends Error { interface PublicEnvironmentInput { appEnv?: string; apiBaseUrl?: string; + stockfishSocketUrl?: string; } function invalidConfiguration(details: string): EnvironmentConfigError { @@ -30,6 +35,34 @@ function invalidConfiguration(details: string): EnvironmentConfigError { ); } +function validateServiceUrl( + field: string, + value: string, + appEnv: AppEnvironment, +): string { + const url = new URL(value); + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw invalidConfiguration(`${field} must use HTTP or HTTPS`); + } + + if (appEnv !== 'development' && url.protocol !== 'https:') { + throw invalidConfiguration(`${field} must use HTTPS outside development`); + } + + if (url.username || url.password) { + throw invalidConfiguration(`${field} must not contain credentials`); + } + + if (url.search || url.hash) { + throw invalidConfiguration( + `${field} must not contain a query string or fragment`, + ); + } + + return url.toString().replace(/\/+$/, ''); +} + export function parsePublicEnvironment( input: PublicEnvironmentInput, ): PublicEnvironment { @@ -46,33 +79,25 @@ export function parsePublicEnvironment( throw invalidConfiguration(details); } - const apiUrl = new URL(result.data.apiBaseUrl); - - if (apiUrl.protocol !== 'http:' && apiUrl.protocol !== 'https:') { - throw invalidConfiguration('apiBaseUrl must use HTTP or HTTPS'); - } - - if (result.data.appEnv !== 'development' && apiUrl.protocol !== 'https:') { - throw invalidConfiguration('apiBaseUrl must use HTTPS outside development'); - } - - if (apiUrl.username || apiUrl.password) { - throw invalidConfiguration('apiBaseUrl must not contain credentials'); - } - - if (apiUrl.search || apiUrl.hash) { - throw invalidConfiguration( - 'apiBaseUrl must not contain a query string or fragment', - ); - } + const { appEnv } = result.data; return Object.freeze({ - appEnv: result.data.appEnv, - apiBaseUrl: apiUrl.toString().replace(/\/+$/, ''), + appEnv, + apiBaseUrl: validateServiceUrl( + 'apiBaseUrl', + result.data.apiBaseUrl, + appEnv, + ), + stockfishSocketUrl: validateServiceUrl( + 'stockfishSocketUrl', + result.data.stockfishSocketUrl, + appEnv, + ), }); } export const env = parsePublicEnvironment({ appEnv: process.env.EXPO_PUBLIC_APP_ENV, apiBaseUrl: process.env.EXPO_PUBLIC_API_BASE_URL, + stockfishSocketUrl: process.env.EXPO_PUBLIC_STOCKFISH_SOCKET_URL, }); diff --git a/test/env.test.ts b/test/env.test.ts index 33f8546..1773a1e 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -1,5 +1,7 @@ import { EnvironmentConfigError, parsePublicEnvironment } from '@/config/env'; +const stockfishSocketUrl = 'https://stockfish.example.org'; + describe('parsePublicEnvironment', () => { it.each(['development', 'preview', 'production'] as const)( 'accepts a valid %s environment', @@ -8,10 +10,12 @@ describe('parsePublicEnvironment', () => { parsePublicEnvironment({ appEnv, apiBaseUrl: 'https://api.example.org/v1/', + stockfishSocketUrl, }), ).toEqual({ appEnv, apiBaseUrl: 'https://api.example.org/v1', + stockfishSocketUrl, }); }, ); @@ -21,10 +25,12 @@ describe('parsePublicEnvironment', () => { parsePublicEnvironment({ appEnv: 'development', apiBaseUrl: 'http://10.0.2.2:8000', + stockfishSocketUrl: 'http://10.0.2.2:8080', }), ).toEqual({ appEnv: 'development', apiBaseUrl: 'http://10.0.2.2:8000', + stockfishSocketUrl: 'http://10.0.2.2:8080', }); }); @@ -35,6 +41,7 @@ describe('parsePublicEnvironment', () => { parsePublicEnvironment({ appEnv, apiBaseUrl: 'http://api.example.org', + stockfishSocketUrl, }), ).toThrow('apiBaseUrl must use HTTPS outside development'); }, @@ -55,6 +62,7 @@ describe('parsePublicEnvironment', () => { parsePublicEnvironment({ appEnv: 'development', apiBaseUrl: 'https://user:password@api.example.org', + stockfishSocketUrl, }), ).toThrow('apiBaseUrl must not contain credentials'); }); @@ -64,6 +72,7 @@ describe('parsePublicEnvironment', () => { parsePublicEnvironment({ appEnv: 'development', apiBaseUrl: 'ftp://api.example.org', + stockfishSocketUrl, }), ).toThrow('apiBaseUrl must use HTTP or HTTPS'); }); @@ -73,7 +82,37 @@ describe('parsePublicEnvironment', () => { parsePublicEnvironment({ appEnv: 'development', apiBaseUrl: 'https://api.example.org?token=public#section', + stockfishSocketUrl, }), ).toThrow('apiBaseUrl must not contain a query string or fragment'); }); + + it('accepts and normalizes the Stockfish socket URL', () => { + expect( + parsePublicEnvironment({ + appEnv: 'development', + apiBaseUrl: 'http://10.0.2.2:8000', + stockfishSocketUrl: 'http://10.0.2.2:8080/', + }).stockfishSocketUrl, + ).toBe('http://10.0.2.2:8080'); + }); + + it('rejects a missing Stockfish socket URL', () => { + expect(() => + parsePublicEnvironment({ + appEnv: 'development', + apiBaseUrl: 'http://10.0.2.2:8000', + }), + ).toThrow('stockfishSocketUrl'); + }); + + it('rejects an HTTP Stockfish socket URL in production', () => { + expect(() => + parsePublicEnvironment({ + appEnv: 'production', + apiBaseUrl: 'https://api.example.org', + stockfishSocketUrl: 'http://stockfish.example.org', + }), + ).toThrow('stockfishSocketUrl must use HTTPS outside development'); + }); }); diff --git a/test/setup.ts b/test/setup.ts index e03abcc..730cfcf 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,3 +2,4 @@ import '@testing-library/react-native'; process.env.EXPO_PUBLIC_APP_ENV ??= 'development'; process.env.EXPO_PUBLIC_API_BASE_URL ??= 'http://10.0.2.2:8000'; +process.env.EXPO_PUBLIC_STOCKFISH_SOCKET_URL ??= 'http://10.0.2.2:8080'; From e20478b4eebb8a9a889ee17a6b5296442b77553e Mon Sep 17 00:00:00 2001 From: Sporkinator Date: Fri, 9 Oct 2026 02:34:52 -0700 Subject: [PATCH 3/5] Added stockfish client interface --- docs/stockfish-socket-contract.md | 48 +++++++++++ src/features/play/services/StockfishClient.ts | 27 ++++++ test/mocks/FakeSocket.ts | 83 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 test/mocks/FakeSocket.ts diff --git a/docs/stockfish-socket-contract.md b/docs/stockfish-socket-contract.md index e69de29..ec9f7ba 100644 --- a/docs/stockfish-socket-contract.md +++ b/docs/stockfish-socket-contract.md @@ -0,0 +1,48 @@ +# Stockfish Socket Contract + +Source: YSTEM-LMS/react, stockfishServer/src/managers/socket.js (commit ) + +## Connection + +- URL: EXPO_PUBLIC_STOCKFISH_SOCKET_URL +- Server: socket.io ^4.8.1 · Mobile client: socket.io-client 4.x +- Transports: TODO (does the server allow only websocket?) + +## Client → server events + +### start-session + +```ts +{ sessionType: 'player-vs-computer', fen: 'start' } +``` + +Server reply: TODO (does it emit anything back? an error event?) + +### evaluate-fen + +```ts +{ fen: string, move: string, level: 1 | 2 | 3 | 4 | 5 } +``` + +- move format: TODO (UCI "e2e4" or SAN "e4"?) +- how level is used: TODO (see StockfishManager.js) + +## Server → client events + +### evaluation-complete + +```ts +TODO: exact payload copied from the server code +``` + +Example payload: TODO (from stockfishServer/src/tests) + +### Errors + +TODO: event name and payload when evaluation fails + +## Unresolved + +- Authentication: TODO (none found / unknown) +- Rate limits: TODO (none found / unknown) +- Session lifetime / multiple sessions per socket: TODO diff --git a/src/features/play/services/StockfishClient.ts b/src/features/play/services/StockfishClient.ts index e69de29..67cce8d 100644 --- a/src/features/play/services/StockfishClient.ts +++ b/src/features/play/services/StockfishClient.ts @@ -0,0 +1,27 @@ +export type StockfishLevel = 1 | 2 | 3 | 4 | 5; + +export interface EngineMove { + move: string; // e.g. "e7e5" + newFen: string; +} + +export interface StockfishClient { + startSession(): Promise; // connects lazily, then starts the session + requestMove(input: { + fen: string; + move: string; + level: StockfishLevel; + }): Promise; + disconnect(): void; +} + +export type SocketListener = (...args: unknown[]) => void; + +export interface StockfishSocket { + readonly connected: boolean; + connect(): void; + disconnect(): void; + emit(event: string, payload?: unknown): void; + on(event: string, listener: SocketListener): void; + off(event: string, listener: SocketListener): void; +} diff --git a/test/mocks/FakeSocket.ts b/test/mocks/FakeSocket.ts new file mode 100644 index 0000000..13b48e2 --- /dev/null +++ b/test/mocks/FakeSocket.ts @@ -0,0 +1,83 @@ +import type { + SocketListener, + StockfishSocket, +} from '@/features/play/services/StockfishClient'; + +export interface EmittedEvent { + event: string; + payload: unknown; +} + +export class FakeSocket implements StockfishSocket { + connected = false; + connectCalls = 0; + disconnectCalls = 0; + readonly emitted: EmittedEvent[] = []; + private readonly listeners = new Map(); + + // ----- Part 1: the socket interface (called by the client) ----- + + connect(): void { + // Only record the call. The test decides when (or whether) the + // connection succeeds, using simulateConnect / simulateConnectError. + this.connectCalls += 1; + } + + disconnect(): void { + this.disconnectCalls += 1; + if (this.connected) { + this.connected = false; + // A real socket fires 'disconnect' with this reason. + this.serverEmit('disconnect', 'io client disconnect'); + } + } + + emit(event: string, payload?: unknown): void { + this.emitted.push({ event, payload }); + } + + on(event: string, listener: SocketListener): void { + const list = this.listeners.get(event) ?? []; + list.push(listener); + this.listeners.set(event, list); + } + + off(event: string, listener: SocketListener): void { + const list = this.listeners.get(event); + if (!list) return; + const index = list.indexOf(listener); + if (index !== -1) list.splice(index, 1); + } + + // ----- Part 2: test controls (called by tests) ----- + + /** Pretend the server sent an event. */ + serverEmit(event: string, ...args: unknown[]): void { + const current = [...(this.listeners.get(event) ?? [])]; + current.forEach((listener) => listener(...args)); + } + + simulateConnect(): void { + this.connected = true; + this.serverEmit('connect'); + } + + simulateConnectError(message = 'Connection refused'): void { + this.serverEmit('connect_error', new Error(message)); + } + + /** Listeners for one event, or for all events if no name is given. */ + listenerCount(event?: string): number { + if (event !== undefined) return this.listeners.get(event)?.length ?? 0; + let total = 0; + this.listeners.forEach((list) => (total += list.length)); + return total; + } + + /** Payloads of every emit with this event name, in order. */ + payloadsFor(event: string): unknown[] { + return this.emitted + .filter((entry) => entry.event === event) + .map((entry) => entry.payload); + } +} From ef2b62aeae89076af5049457844e57b04cfc091a Mon Sep 17 00:00:00 2001 From: Sporkinator Date: Fri, 9 Oct 2026 03:40:46 -0700 Subject: [PATCH 4/5] Added StockfishClient tests --- src/features/play/services/StockfishClient.ts | 352 ++++++++++++++ test/StockfishClient.test.ts | 458 ++++++++++++++++++ 2 files changed, 810 insertions(+) create mode 100644 test/StockfishClient.test.ts diff --git a/src/features/play/services/StockfishClient.ts b/src/features/play/services/StockfishClient.ts index 67cce8d..f374f35 100644 --- a/src/features/play/services/StockfishClient.ts +++ b/src/features/play/services/StockfishClient.ts @@ -1,3 +1,8 @@ +import { io } from 'socket.io-client'; +import { z } from 'zod'; + +import { env } from '@/config/env'; + export type StockfishLevel = 1 | 2 | 3 | 4 | 5; export interface EngineMove { @@ -25,3 +30,350 @@ export interface StockfishSocket { on(event: string, listener: SocketListener): void; off(event: string, listener: SocketListener): void; } + +export class StockfishClientError extends Error { + constructor(message: string) { + super(message); + this.name = 'StockfishClientError'; + } +} + +export interface StockfishClientOptions { + /** Defaults to EXPO_PUBLIC_STOCKFISH_SOCKET_URL. */ + url?: string; + /** Tests pass a factory that returns a FakeSocket. */ + createSocket?: (url: string) => StockfishSocket; + /** How long to wait for each server reply. */ + timeoutMs?: number; +} + +export function isStockfishLevel(value: unknown): value is StockfishLevel { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value >= 1 && + value <= 5 + ); +} + +function createRealSocket(url: string): StockfishSocket { + return io(url, { + // Lazy: nothing connects until the client calls socket.connect(). + autoConnect: false, + // React Native works best with WebSocket only (no HTTP long-polling). + transports: ['websocket'], + // The server deletes the Stockfish session when the socket drops, so a + // silent automatic reconnect would leave us without a session. Instead, + // callers start a new session with startSession(). + reconnection: false, + }); +} + +// ----- Payload validation (the server is untrusted input) ----- + +const sessionStartedSchema = z.object({ + success: z.literal(true), +}); + +const evaluationCompleteSchema = z.object({ + mode: z.literal('move'), + move: z.string().min(1), + newFEN: z.string().min(1), +}); + +function describeIssues(error: z.ZodError): string { + return error.issues + .map((issue) => `${issue.path.join('.') || 'payload'}: ${issue.message}`) + .join('; '); +} + +function parseSessionStarted(payload: unknown): void { + const result = sessionStartedSchema.safeParse(payload); + if (!result.success) { + throw new StockfishClientError( + `Invalid session-started payload: ${describeIssues(result.error)}`, + ); + } +} + +function parseEvaluationComplete(payload: unknown): EngineMove { + const result = evaluationCompleteSchema.safeParse(payload); + if (!result.success) { + throw new StockfishClientError( + `Invalid evaluation-complete payload: ${describeIssues(result.error)}`, + ); + } + return { move: result.data.move, newFen: result.data.newFEN }; +} + +/** Turns a failure event's payload into a readable message. */ +function describeFailure(event: string, payload: unknown): string { + let detail = 'no details provided'; + + if (payload instanceof Error) { + detail = payload.message; // connect_error + } else if (typeof payload === 'string') { + detail = payload; // disconnect reason + } else if ( + typeof payload === 'object' && + payload !== null && + 'error' in payload && + typeof payload.error === 'string' + ) { + detail = payload.error; // session-error / evaluation-error + } + + return `Stockfish ${event}: ${detail}`; +} + +// ----- Event -> Promise helper ----- + +type Canceller = (error: Error) => void; + +interface ReplyOptions { + socket: StockfishSocket; + successEvent: string; + failureEvents: readonly string[]; + timeoutMs: number; + /** Every in-flight wait registers here so disconnect() can cancel it. */ + cancellers: Set; + parse: (payload: unknown) => T; +} + +/** + * Waits for successEvent, a failure event, a timeout, or cancellation — + * whichever happens first. Every path removes the listeners and clears the + * timer, so nothing leaks. + * + * Listeners are registered immediately, so call this BEFORE emitting the + * request; otherwise a fast reply could be missed. + */ +function waitForReply({ + socket, + successEvent, + failureEvents, + timeoutMs, + cancellers, + parse, +}: ReplyOptions): Promise { + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + + const failureHandlers = failureEvents.map((event) => { + const handler: SocketListener = (payload) => { + cleanup(); + reject(new StockfishClientError(describeFailure(event, payload))); + }; + return { event, handler }; + }); + + const onSuccess: SocketListener = (payload) => { + cleanup(); + try { + resolve(parse(payload)); + } catch (error) { + reject(error); + } + }; + + const cancel: Canceller = (error) => { + cleanup(); + reject(error); + }; + + function cleanup(): void { + clearTimeout(timer); + socket.off(successEvent, onSuccess); + failureHandlers.forEach(({ event, handler }) => + socket.off(event, handler), + ); + cancellers.delete(cancel); + } + + socket.on(successEvent, onSuccess); + failureHandlers.forEach(({ event, handler }) => socket.on(event, handler)); + cancellers.add(cancel); + + timer = setTimeout(() => { + cleanup(); + reject( + new StockfishClientError( + `Timed out after ${timeoutMs} ms waiting for ${successEvent}`, + ), + ); + }, timeoutMs); + }); +} + +// ----- The client ----- + +export function createStockfishClient({ + url = env.stockfishSocketUrl, + createSocket = createRealSocket, + timeoutMs = 10_000, +}: StockfishClientOptions = {}): StockfishClient { + let socket: StockfishSocket | null = null; + let connecting: Promise | null = null; + let session: Promise | null = null; + let sessionReady = false; + let pending = false; + const cancellers = new Set(); + + function ensureConnected(): Promise { + if (!socket) { + socket = createSocket(url); + } + const current = socket; + + if (current.connected) { + return Promise.resolve(current); + } + + // Reuse an attempt that is already in progress instead of connecting twice. + if (connecting) { + return connecting; + } + + const attempt = waitForReply({ + socket: current, + successEvent: 'connect', + failureEvents: ['connect_error'], + timeoutMs, + cancellers, + parse: () => current, + }); + connecting = attempt; + + attempt.then( + () => { + if (connecting === attempt) connecting = null; + }, + () => { + if (connecting === attempt) connecting = null; + // Stop Socket.IO from retrying in the background. If disconnect() + // already tore this socket down, there is nothing left to do. + if (socket === current) { + socket = null; + current.disconnect(); + } + }, + ); + + current.connect(); + return attempt; + } + + async function openSession(): Promise { + const current = await ensureConnected(); + + const reply = waitForReply({ + socket: current, + successEvent: 'session-started', + failureEvents: ['session-error', 'disconnect'], + timeoutMs, + cancellers, + parse: parseSessionStarted, + }); + current.emit('start-session', { + sessionType: 'player-vs-computer', + fen: 'start', + }); + await reply; + + if (socket === current) { + sessionReady = true; + } + } + + function startSession(): Promise { + // The connection dropped since the last session: the server has already + // deleted that session, so start over. + if (sessionReady && !socket?.connected) { + session = null; + sessionReady = false; + } + + // The server rejects a second start-session on the same socket, so + // concurrent callers share one attempt. + if (!session) { + const attempt = openSession(); + session = attempt; + attempt.catch(() => { + if (session === attempt) session = null; + }); + } + + return session; + } + + async function requestMove({ + fen, + move, + level, + }: { + fen: string; + move: string; + level: StockfishLevel; + }): Promise { + // TypeScript can be bypassed (e.g. `as StockfishLevel`), so check at runtime. + if (!isStockfishLevel(level)) { + throw new StockfishClientError( + `Difficulty level must be an integer from 1 to 5 (received ${String(level)})`, + ); + } + + const current = socket; + if (!current || !sessionReady) { + throw new StockfishClientError( + 'No Stockfish session. Call startSession() before requesting a move.', + ); + } + if (!current.connected) { + throw new StockfishClientError( + 'Connection to the Stockfish server was lost. Call startSession() again.', + ); + } + + // evaluation-complete carries no request ID, so only one request may be + // in flight at a time; otherwise replies could be matched to the wrong call. + if (pending) { + throw new StockfishClientError('A move request is already in progress.'); + } + + pending = true; + try { + const reply = waitForReply({ + socket: current, + successEvent: 'evaluation-complete', + failureEvents: ['evaluation-error', 'disconnect'], + timeoutMs, + cancellers, + parse: parseEvaluationComplete, + }); + // OPEN QUESTION: the server treats `level` as search depth (1–30). Until + // the team decides whether 1–5 should map to larger depths, it is sent + // unchanged. Any mapping belongs on this line only. + current.emit('evaluate-fen', { fen, move, level }); + return await reply; + } finally { + pending = false; + } + } + + function disconnect(): void { + const current = socket; + + socket = null; + connecting = null; + session = null; + sessionReady = false; + + // Reject everything still waiting and remove its listeners. + const error = new StockfishClientError('Stockfish client disconnected.'); + [...cancellers].forEach((cancel) => cancel(error)); + + current?.disconnect(); + } + + return { startSession, requestMove, disconnect }; +} diff --git a/test/StockfishClient.test.ts b/test/StockfishClient.test.ts new file mode 100644 index 0000000..e073ab4 --- /dev/null +++ b/test/StockfishClient.test.ts @@ -0,0 +1,458 @@ +import { env } from '@/config/env'; +import { + createStockfishClient, + StockfishClientError, + type StockfishClient, + type StockfishLevel, +} from '@/features/play/services/StockfishClient'; + +import { FakeSocket } from './mocks/FakeSocket'; + +const TIMEOUT_MS = 1_000; + +// Position after 1. e4 (the player's move is applied by the server). +const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; +const AFTER_E5_FEN = + 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2'; + +// Shape copied from stockfishServer/src/managers/StockfishManager.js (move mode). +const ENGINE_REPLY = { + mode: 'move', + move: 'e7e5', + moveDetails: { from: 'e7', to: 'e5', piece: 'p', color: 'b', san: 'e5' }, + newFEN: AFTER_E5_FEN, +}; + +/** + * Lets pending promise callbacks run. Uses the real setImmediate so it still + * works while Jest's fake timers are active. + */ +function flushPromises(): Promise { + return new Promise((resolve) => + jest.requireActual('timers').setImmediate(resolve), + ); +} + +function setup() { + const socket = new FakeSocket(); + const createSocket = jest.fn((url: string) => { + void url; + return socket; + }); + const client = createStockfishClient({ + url: 'http://stockfish.test', + createSocket, + timeoutMs: TIMEOUT_MS, + }); + return { socket, createSocket, client }; +} + +/** Plays the server's side of a successful connect + start-session. */ +async function startSession( + client: StockfishClient, + socket: FakeSocket, +): Promise { + const started = client.startSession(); + socket.simulateConnect(); + await flushPromises(); + socket.serverEmit('session-started', { success: true, id: 'socket-1' }); + await started; +} + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('createStockfishClient: connection', () => { + it('uses the configured Stockfish URL by default', async () => { + const socket = new FakeSocket(); + const createSocket = jest.fn((url: string) => { + void url; + return socket; + }); + const client = createStockfishClient({ createSocket }); + + await startSession(client, socket); + + expect(createSocket).toHaveBeenCalledWith(env.stockfishSocketUrl); + }); + + it('uses an explicitly provided URL', async () => { + const { client, socket, createSocket } = setup(); + + await startSession(client, socket); + + expect(createSocket).toHaveBeenCalledWith('http://stockfish.test'); + }); + + it('connects only when a session is requested', () => { + const { client, socket, createSocket } = setup(); + + expect(createSocket).not.toHaveBeenCalled(); + expect(socket.connectCalls).toBe(0); + + void client.startSession().catch(() => undefined); + + expect(createSocket).toHaveBeenCalledTimes(1); + expect(socket.connectCalls).toBe(1); + client.disconnect(); + }); + + it('reports a connection failure and stops retrying', async () => { + const { client, socket } = setup(); + + const started = client.startSession(); + socket.simulateConnectError('Connection refused'); + + await expect(started).rejects.toThrow(StockfishClientError); + await expect(started).rejects.toThrow('Connection refused'); + expect(socket.disconnectCalls).toBe(1); + expect(socket.listenerCount()).toBe(0); + }); + + it('rejects when the connection times out', async () => { + jest.useFakeTimers(); + const { client, socket } = setup(); + + const started = client.startSession(); + const assertion = expect(started).rejects.toThrow('Timed out'); + jest.advanceTimersByTime(TIMEOUT_MS); + await assertion; + + expect(socket.listenerCount()).toBe(0); + }); +}); + +describe('createStockfishClient: startSession', () => { + it('sends the start-session payload', async () => { + const { client, socket } = setup(); + + await startSession(client, socket); + + expect(socket.payloadsFor('start-session')).toEqual([ + { sessionType: 'player-vs-computer', fen: 'start' }, + ]); + }); + + it('does not create duplicate connections, sessions, or listeners', async () => { + const { client, socket, createSocket } = setup(); + + const first = client.startSession(); + const second = client.startSession(); + expect(socket.listenerCount('connect')).toBe(1); + + socket.simulateConnect(); + await flushPromises(); + expect(socket.listenerCount('session-started')).toBe(1); + + socket.serverEmit('session-started', { success: true, id: 'socket-1' }); + await Promise.all([first, second]); + await client.startSession(); + + expect(createSocket).toHaveBeenCalledTimes(1); + expect(socket.connectCalls).toBe(1); + expect(socket.payloadsFor('start-session')).toHaveLength(1); + expect(socket.listenerCount()).toBe(0); + }); + + it('rejects when the server cannot start a session', async () => { + const { client, socket } = setup(); + + const started = client.startSession(); + socket.simulateConnect(); + await flushPromises(); + socket.serverEmit('session-error', { + error: 'Chess engine failed to start', + }); + + await expect(started).rejects.toThrow('Chess engine failed to start'); + expect(socket.listenerCount()).toBe(0); + }); + + it('rejects a malformed session-started payload', async () => { + const { client, socket } = setup(); + + const started = client.startSession(); + socket.simulateConnect(); + await flushPromises(); + socket.serverEmit('session-started', { success: false }); + + await expect(started).rejects.toThrow('Invalid session-started payload'); + }); +}); + +describe('createStockfishClient: requestMove', () => { + it('requires a started session', async () => { + const { client, socket } = setup(); + + await expect( + client.requestMove({ fen: START_FEN, move: 'e2e4', level: 3 }), + ).rejects.toThrow('Call startSession()'); + expect(socket.payloadsFor('evaluate-fen')).toHaveLength(0); + }); + + it('sends the FEN and the player move', async () => { + const { client, socket } = setup(); + await startSession(client, socket); + + const result = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level: 3, + }); + + expect(socket.payloadsFor('evaluate-fen')).toEqual([ + { fen: START_FEN, move: 'e2e4', level: 3 }, + ]); + socket.serverEmit('evaluation-complete', ENGINE_REPLY); + await result; + }); + + it.each([1, 2, 3, 4, 5] as const)( + 'sends difficulty level %i', + async (level) => { + const { client, socket } = setup(); + await startSession(client, socket); + + const result = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level, + }); + + expect(socket.payloadsFor('evaluate-fen')).toEqual([ + expect.objectContaining({ level }), + ]); + socket.serverEmit('evaluation-complete', ENGINE_REPLY); + await result; + }, + ); + + it.each([0, 6, 2.5, -1, Number.NaN])( + 'rejects invalid difficulty %p without sending anything', + async (level) => { + const { client, socket } = setup(); + await startSession(client, socket); + + const result = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level: level as StockfishLevel, + }); + + await expect(result).rejects.toThrow(StockfishClientError); + await expect(result).rejects.toThrow('Difficulty level'); + expect(socket.payloadsFor('evaluate-fen')).toHaveLength(0); + }, + ); + + it('resolves with the engine move when evaluation-complete arrives', async () => { + const { client, socket } = setup(); + await startSession(client, socket); + + const result = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level: 3, + }); + socket.serverEmit('evaluation-complete', ENGINE_REPLY); + + await expect(result).resolves.toEqual({ + move: 'e7e5', + newFen: AFTER_E5_FEN, + }); + expect(socket.listenerCount()).toBe(0); + }); + + it('rejects a malformed evaluation-complete payload', async () => { + const { client, socket } = setup(); + await startSession(client, socket); + + const result = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level: 3, + }); + socket.serverEmit('evaluation-complete', { mode: 'move' }); + + await expect(result).rejects.toThrow('Invalid evaluation-complete payload'); + expect(socket.listenerCount()).toBe(0); + }); + + it('rejects when the server reports an evaluation error', async () => { + const { client, socket } = setup(); + await startSession(client, socket); + + const result = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level: 3, + }); + socket.serverEmit('evaluation-error', { + error: 'Invalid move from engine', + }); + + await expect(result).rejects.toThrow('Invalid move from engine'); + expect(socket.listenerCount()).toBe(0); + }); + + it('rejects on timeout and removes its listeners', async () => { + jest.useFakeTimers(); + const { client, socket } = setup(); + await startSession(client, socket); + + const result = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level: 3, + }); + const assertion = expect(result).rejects.toThrow('Timed out'); + jest.advanceTimersByTime(TIMEOUT_MS); + await assertion; + + expect(socket.listenerCount()).toBe(0); + }); + + it('rejects a second request while one is in progress', async () => { + const { client, socket } = setup(); + await startSession(client, socket); + + const first = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level: 3, + }); + const second = client.requestMove({ + fen: START_FEN, + move: 'd2d4', + level: 3, + }); + + await expect(second).rejects.toThrow('already in progress'); + expect(socket.payloadsFor('evaluate-fen')).toHaveLength(1); + + socket.serverEmit('evaluation-complete', ENGINE_REPLY); + await expect(first).resolves.toEqual({ + move: 'e7e5', + newFen: AFTER_E5_FEN, + }); + }); + + it('does not accumulate listeners across requests', async () => { + const { client, socket } = setup(); + await startSession(client, socket); + + for (let i = 0; i < 3; i += 1) { + const result = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level: 3, + }); + expect(socket.listenerCount('evaluation-complete')).toBe(1); + socket.serverEmit('evaluation-complete', ENGINE_REPLY); + await result; + } + + expect(socket.listenerCount()).toBe(0); + }); +}); + +describe('createStockfishClient: disconnect', () => { + it('disconnects cleanly and removes every listener', async () => { + const { client, socket } = setup(); + await startSession(client, socket); + + client.disconnect(); + + expect(socket.disconnectCalls).toBe(1); + expect(socket.connected).toBe(false); + expect(socket.listenerCount()).toBe(0); + }); + + it('rejects a pending move request', async () => { + const { client, socket } = setup(); + await startSession(client, socket); + + const result = client.requestMove({ + fen: START_FEN, + move: 'e2e4', + level: 3, + }); + client.disconnect(); + + await expect(result).rejects.toThrow('disconnected'); + expect(socket.listenerCount()).toBe(0); + }); + + it('cancels a connection attempt that is still in progress', async () => { + const { client, socket } = setup(); + + const started = client.startSession(); + client.disconnect(); + + await expect(started).rejects.toThrow('disconnected'); + expect(socket.disconnectCalls).toBe(1); + expect(socket.listenerCount()).toBe(0); + }); + + it('is safe to call before connecting and more than once', () => { + const { client, socket, createSocket } = setup(); + + expect(() => { + client.disconnect(); + client.disconnect(); + }).not.toThrow(); + expect(createSocket).not.toHaveBeenCalled(); + expect(socket.disconnectCalls).toBe(0); + }); + + it('creates a fresh socket for a new session after disconnecting', async () => { + const sockets: FakeSocket[] = []; + const client = createStockfishClient({ + url: 'http://stockfish.test', + createSocket: () => { + const created = new FakeSocket(); + sockets.push(created); + return created; + }, + timeoutMs: TIMEOUT_MS, + }); + + // The socket is only created once startSession() runs, so play the + // server's side by hand instead of using the startSession helper. + const started = client.startSession(); + sockets[0].simulateConnect(); + await flushPromises(); + sockets[0].serverEmit('session-started', { success: true, id: 'socket-1' }); + await started; + + client.disconnect(); + const restarted = client.startSession(); + sockets[1].simulateConnect(); + await flushPromises(); + sockets[1].serverEmit('session-started', { success: true, id: 'socket-2' }); + await restarted; + + expect(sockets).toHaveLength(2); + expect(sockets[0].listenerCount()).toBe(0); + expect(sockets[1].payloadsFor('start-session')).toHaveLength(1); + }); + + it('starts a new session after the connection drops', async () => { + const { client, socket } = setup(); + await startSession(client, socket); + + // The server or network closes the connection. + socket.connected = false; + socket.serverEmit('disconnect', 'transport close'); + + await expect( + client.requestMove({ fen: START_FEN, move: 'e2e4', level: 3 }), + ).rejects.toThrow('Call startSession() again'); + + await startSession(client, socket); + + expect(socket.connectCalls).toBe(2); + expect(socket.payloadsFor('start-session')).toHaveLength(2); + }); +}); From 2b8f112509082ecdf711dc29fe885223a88dabff Mon Sep 17 00:00:00 2001 From: Sporkinator Date: Sat, 10 Oct 2026 21:46:07 -0700 Subject: [PATCH 5/5] Return only the engine move from requestMove and document the Stockfish socket contract --- docs/stockfish-socket-contract.md | 177 ++++++++++++++++-- src/features/play/services/StockfishClient.ts | 21 ++- test/StockfishClient.test.ts | 21 +-- 3 files changed, 181 insertions(+), 38 deletions(-) diff --git a/docs/stockfish-socket-contract.md b/docs/stockfish-socket-contract.md index ec9f7ba..e70dffc 100644 --- a/docs/stockfish-socket-contract.md +++ b/docs/stockfish-socket-contract.md @@ -1,48 +1,191 @@ # Stockfish Socket Contract -Source: YSTEM-LMS/react, stockfishServer/src/managers/socket.js (commit ) +The Socket.IO protocol between the mobile app and the Y STEM Stockfish server, +and how `src/features/play/services/StockfishClient.ts` uses it. + +**Sources** (repository `YSTEM-LMS/react`, commit `1a5928e`): + +- `stockfishServer/src/index.js`: server setup, CORS +- `stockfishServer/src/managers/socket.js`: event handlers +- `stockfishServer/src/managers/StockfishManager.js`: engine sessions and replies +- `react-ystemandchess/src/features/engine/PlayComputer.tsx`: the website's + player-vs-computer client + +Server behaviour that depends on `chess.js` was checked against the server's +locked version, `chess.js` 0.11.0. ## Connection -- URL: EXPO_PUBLIC_STOCKFISH_SOCKET_URL -- Server: socket.io ^4.8.1 · Mobile client: socket.io-client 4.x -- Transports: TODO (does the server allow only websocket?) +| Item | Value | +| ---------- | ----------------------------------------------------------------------------------------------------------- | +| URL | `EXPO_PUBLIC_STOCKFISH_SOCKET_URL` (local default `http://10.0.2.2:8080`; server `PORT` defaults to `8080`) | +| Versions | Server `socket.io` ^4.8.1 · mobile `socket.io-client` ^4.8.4 (same major version) | +| Transports | Server allows the defaults (polling and WebSocket). The website and mobile both use `['websocket']` | +| Namespace | Default (`/`) | +| CORS | Applies to browsers only. Requests without an `Origin` header (native apps) are accepted | + +Mobile sets `autoConnect: false` (lazy connection) and `reconnection: false`. +The server deletes the session when a socket disconnects, so an automatic +reconnect would leave the client without a session. Callers run +`startSession()` again instead. + +## Session lifecycle + +1. Client connects. +2. Client emits `start-session`. The server spawns one Stockfish process for + this socket. +3. Client emits `evaluate-fen` as many times as needed, **one at a time**. +4. Client emits `end-session` or disconnects. The server kills the process. + +- One session per socket. A second `start-session` on the same socket returns + `session-error` with `"Session already exists!"`. +- Disconnecting deletes the session. Reconnecting requires a new + `start-session`. ## Client → server events -### start-session +### `start-session` ```ts { sessionType: 'player-vs-computer', fen: 'start' } ``` -Server reply: TODO (does it emit anything back? an error event?) +- `sessionType` is stored but does not change engine behaviour. +- `fen` sets the session's initial position. +- **`'start'` is not a valid FEN.** chess.js 0.11 ignores it without an error + and leaves an empty board. This is harmless, because every `evaluate-fen` + reloads the position from its own `fen`. The website sends a full FEN + instead. + +Replies: -### evaluate-fen +- success: `session-started` with `{ success: true, id: string }` (`id` is the + socket ID) +- failure: `session-error` with `{ error: string }` + +If the Stockfish binary fails to launch, the server emits `session-error` +(`"Chess engine failed to start"`) **after** `session-started` has already +been sent. + +### `evaluate-fen` ```ts { fen: string, move: string, level: 1 | 2 | 3 | 4 | 5 } ``` -- move format: TODO (UCI "e2e4" or SAN "e4"?) -- how level is used: TODO (see StockfishManager.js) +| Field | Meaning | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fen` | Position to evaluate | +| `move` | Optional move to apply first, in **UCI** format (`e2e4`, promotions like `e7e8q`). Space-separated lists are passed through to the engine. Send `''` for none | +| `level` | Engine **search depth** (`go depth `). Mobile sends difficulty 1–5 directly (confirmed by the team). The server accepts up to 30 and has no lower bound. The website uses 1, 5, 10, 15, 20 | + +Replies: `evaluation-complete` or `evaluation-error` (below). Without a +session: `evaluation-error` with `"Session not found!"`. + +### Other events (not used by the mobile client) + +| Event | Payload | Reply | +| ------------- | ----------------- | --------------------------------------------------------------------------------- | +| `update-fen` | `{ fen: string }` | None on success. `update-error` with `{ err: string }` (note: `err`, not `error`) | +| `end-session` | none | `session-ended` with `{ success: true }` | ## Server → client events -### evaluation-complete +### `evaluation-complete` + +Sessions started through `start-session` are always in **move mode**: ```ts -TODO: exact payload copied from the server code +{ + mode: 'move', + move: string, // engine's best move, UCI, e.g. "e7e5" + moveDetails: object | null, + newFEN: string, +} ``` -Example payload: TODO (from stockfishServer/src/tests) +`moveDetails` is a chess.js 0.11 move object, or `null` if chess.js could not +apply the move: + +```ts +{ color: 'w' | 'b', from: string, to: string, flags: string, piece: string, + san: string, captured?: string, promotion?: string } +``` + +Example. Request `{ fen: , move: '', level: 3 }`, reply: + +```json +{ + "mode": "move", + "move": "e7e5", + "moveDetails": { + "color": "b", + "from": "e7", + "to": "e5", + "flags": "b", + "piece": "p", + "san": "e5" + }, + "newFEN": "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2" +} +``` + +An info-mode shape, `{ mode: 'info', output: string[] }`, exists for +server-internal analysis sessions. `start-session` cannot enable it. + +#### Caveats + +- **`newFEN` and `moveDetails` are wrong when `move` is not empty.** The engine + searches from `fen` + `move`, but the server applies the engine's reply to + `fen` alone. The reply is then illegal, chess.js 0.11 returns `null` without + throwing, `moveDetails` is `null`, and `newFEN` equals the request's `fen` + (neither move applied). The `move` field itself is still correct. The website + avoids this by sending the position **after** the player's move with + `move: ''`, and it applies the engine's `move` locally, ignoring `newFEN`. + **Decision (team):** the mobile client returns only `move`. Callers apply it + to their own board, and `newFEN` and `moveDetails` are ignored. +- **No legal moves.** In checkmate or stalemate, Stockfish answers + `bestmove (none)`. This arrives as `evaluation-complete` with + `move: '(none)'` and `moveDetails: null`, not as `evaluation-error`. + +### `evaluation-error` + +Payload: `{ error: string }`, e.g. `"Session not found!"` or +`"Invalid move from engine"`. + +### `session-started`, `session-error`, `session-ended` + +See [Session lifecycle](#session-lifecycle) and the event tables above. + +## Request matching + +Replies carry no request ID. The server keeps one pending evaluation per +session, and a new `evaluate-fen` resets the previous one. The mobile client +therefore allows only one request at a time and rejects a second call while +the first is pending. + +## How the mobile client uses this contract -### Errors +| Client method | Emits | Resolves on | Rejects on | +| ---------------- | ------------------------------------------ | --------------------------------- | --------------------------------------------------------------------------------------------- | +| `startSession()` | `start-session` (after a lazy connect) | `session-started` (validated) | `connect_error`, `session-error`, `disconnect`, timeout | +| `requestMove()` | `evaluate-fen` with `{ fen, move, level }` | `evaluation-complete` (validated) | invalid level, no session, request already pending, `evaluation-error`, `disconnect`, timeout | +| `disconnect()` | none (closes the socket) | n/a | Rejects anything still pending and removes all listeners | -TODO: event name and payload when evaluation fails +- Each reply has a 10 second timeout (`timeoutMs`). +- `requestMove()` resolves with `{ move }` only (UCI). The client validates + `mode: 'move'` and `move` with zod, and ignores `newFEN` and `moveDetails` + (see Caveats). Callers apply the move to their own board. +- Screens never touch the socket. They use the `StockfishClient` interface. ## Unresolved -- Authentication: TODO (none found / unknown) -- Rate limits: TODO (none found / unknown) -- Session lifetime / multiple sessions per socket: TODO +- **Authentication: none.** The server has no handshake auth or tokens. Anyone + who can reach the URL can open sessions. The mobile client sends no + credentials (the issue says not to invent any). +- **Rate limits: none on the socket.** `express-rate-limit` is listed in the + server's `package.json` but is not applied. Each session spawns a Stockfish + process, so connection or session limits are an open server-side question. +- **`(none)` moves** at game end are not yet handled specially by the client. +- **Staging and production URLs** for the Stockfish server are not yet + provided. diff --git a/src/features/play/services/StockfishClient.ts b/src/features/play/services/StockfishClient.ts index f374f35..fa356b3 100644 --- a/src/features/play/services/StockfishClient.ts +++ b/src/features/play/services/StockfishClient.ts @@ -5,9 +5,16 @@ import { env } from '@/config/env'; export type StockfishLevel = 1 | 2 | 3 | 4 | 5; +/** + * The engine's reply move in UCI format (e.g. "e7e5", promotions like "e7e8q"). + * + * The server's `newFEN` field is deliberately not exposed: when a player move + * is sent, the server applies the engine's reply to the position *before* the + * player's move, so `newFEN` is wrong. Callers apply `move` to their own board + * instead, as the website does. See docs/stockfish-socket-contract.md. + */ export interface EngineMove { - move: string; // e.g. "e7e5" - newFen: string; + move: string; } export interface StockfishClient { @@ -75,10 +82,11 @@ const sessionStartedSchema = z.object({ success: z.literal(true), }); +// Only the fields the client uses are validated. `newFEN` and `moveDetails` +// are unreliable when a player move is sent (see EngineMove) and are ignored. const evaluationCompleteSchema = z.object({ mode: z.literal('move'), move: z.string().min(1), - newFEN: z.string().min(1), }); function describeIssues(error: z.ZodError): string { @@ -103,7 +111,7 @@ function parseEvaluationComplete(payload: unknown): EngineMove { `Invalid evaluation-complete payload: ${describeIssues(result.error)}`, ); } - return { move: result.data.move, newFen: result.data.newFEN }; + return { move: result.data.move }; } /** Turns a failure event's payload into a readable message. */ @@ -350,9 +358,8 @@ export function createStockfishClient({ cancellers, parse: parseEvaluationComplete, }); - // OPEN QUESTION: the server treats `level` as search depth (1–30). Until - // the team decides whether 1–5 should map to larger depths, it is sent - // unchanged. Any mapping belongs on this line only. + // Difficulty maps directly to the server's search depth (level 3 means + // `go depth 3`), as confirmed by the team, so it is sent unchanged. current.emit('evaluate-fen', { fen, move, level }); return await reply; } finally { diff --git a/test/StockfishClient.test.ts b/test/StockfishClient.test.ts index e073ab4..efadebd 100644 --- a/test/StockfishClient.test.ts +++ b/test/StockfishClient.test.ts @@ -10,17 +10,16 @@ import { FakeSocket } from './mocks/FakeSocket'; const TIMEOUT_MS = 1_000; -// Position after 1. e4 (the player's move is applied by the server). const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; -const AFTER_E5_FEN = - 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2'; -// Shape copied from stockfishServer/src/managers/StockfishManager.js (move mode). +// What the real server sends for { fen: START_FEN, move: 'e2e4' }: the engine's +// move is correct, but moveDetails is null and newFEN is unchanged, because the +// server applies e7e5 to the position before e2e4. The client returns only `move`. const ENGINE_REPLY = { mode: 'move', move: 'e7e5', - moveDetails: { from: 'e7', to: 'e5', piece: 'p', color: 'b', san: 'e5' }, - newFEN: AFTER_E5_FEN, + moveDetails: null, + newFEN: START_FEN, }; /** @@ -257,10 +256,7 @@ describe('createStockfishClient: requestMove', () => { }); socket.serverEmit('evaluation-complete', ENGINE_REPLY); - await expect(result).resolves.toEqual({ - move: 'e7e5', - newFen: AFTER_E5_FEN, - }); + await expect(result).resolves.toEqual({ move: 'e7e5' }); expect(socket.listenerCount()).toBe(0); }); @@ -332,10 +328,7 @@ describe('createStockfishClient: requestMove', () => { expect(socket.payloadsFor('evaluate-fen')).toHaveLength(1); socket.serverEmit('evaluation-complete', ENGINE_REPLY); - await expect(first).resolves.toEqual({ - move: 'e7e5', - newFen: AFTER_E5_FEN, - }); + await expect(first).resolves.toEqual({ move: 'e7e5' }); }); it('does not accumulate listeners across requests', async () => {