openapi: 3.0.3 info: title: Battleship ESP32 API version: 1.0.0 description: | Local HTTP API for the ESP32-C6 Battleship MVP. All responses are JSON, UTF-8, and carry `Cache-Control: no-store`. The ESP32 is authoritative. WebSocket synchronization at `/ws` is documented in API_CONTRACT.md; it is not an HTTP request/response operation and is therefore outside this OpenAPI document. servers: - url: http://{device-address} variables: device-address: default: 192.168.1.50 description: DHCP address of the ESP32 on the local network. paths: /api/info: get: summary: Read public device and slot availability responses: '200': description: Public game metadata. content: application/json: schema: $ref: '#/components/schemas/Info' /api/health: get: summary: Read device diagnostics without secrets responses: '200': description: Current resource and network diagnostics. content: application/json: schema: $ref: '#/components/schemas/Health' /api/network/status: get: summary: Read unauthenticated network configuration status without credentials responses: '200': { description: Network state and Russian status message. } /api/network/scan: get: summary: Start or retrieve a bounded unauthenticated Wi-Fi scan responses: '200': { description: `scanning` or a bounded list of SSIDs; no passwords. } '409': { $ref: '#/components/responses/Conflict' } /api/network/validate: post: summary: Validate credentials and persist them only after association succeeds requestBody: required: true content: application/json: schema: type: object required: [ssid, password] properties: ssid: { type: string, minLength: 1, maxLength: 32 } password: { type: string, maxLength: 63, writeOnly: true } responses: '200': { description: Validation started; poll network status for result. } '400': { $ref: '#/components/responses/BadRequest' } '409': { $ref: '#/components/responses/Conflict' } /api/network/delete: post: summary: Delete the saved network profile and enter fallback mode responses: '200': { description: Profile deleted. } '409': { $ref: '#/components/responses/Conflict' } /api/session/join: post: summary: Create a session and request a role requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/JoinRequest' responses: '200': description: Session created. content: application/json: schema: $ref: '#/components/schemas/SessionCreated' '400': { $ref: '#/components/responses/BadRequest' } '409': { $ref: '#/components/responses/Conflict' } '413': { $ref: '#/components/responses/PayloadTooLarge' } /api/session/resume: post: summary: Resume a role using its opaque token requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TokenRequest' responses: '200': description: Session resumed. content: application/json: schema: $ref: '#/components/schemas/SessionResumed' '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '413': { $ref: '#/components/responses/PayloadTooLarge' } /api/session/leave: post: summary: Release only the authenticated session requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } } responses: &recoveryResponses '200': { $ref: '#/components/responses/RecoveryAccepted' } '400': { $ref: '#/components/responses/BadRequest' } '409': { $ref: '#/components/responses/Conflict' } '413': { $ref: '#/components/responses/PayloadTooLarge' } /api/session/profile-reset: post: summary: Release the authenticated session for a browser-local profile reset requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } } responses: *recoveryResponses /api/game/config: post: summary: Set the game mode (Player 1, lobby only) requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ConfigRequest' responses: &commandResponses '200': { $ref: '#/components/responses/CommandAccepted' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '409': { $ref: '#/components/responses/Conflict' } '413': { $ref: '#/components/responses/PayloadTooLarge' } '503': { $ref: '#/components/responses/ServerBusy' } /api/game/start: post: summary: Start a configured game (Player 1) requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/GameRequest' responses: *commandResponses /api/game/shot: post: summary: Fire at an opponent cell requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ShotRequest' responses: *commandResponses /api/game/rematch: post: summary: Confirm a rematch after a finished game requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/GameRequest' responses: *commandResponses /api/game/abort: post: summary: Abort a human game with a disconnected opponent description: Available only to Player 1 in the locked human-versus-human case. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/GameRequest' responses: *commandResponses /api/game/reset: post: summary: Player-only full in-memory game reset requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } } responses: '200': { $ref: '#/components/responses/RecoveryAccepted' } '400': { $ref: '#/components/responses/BadRequest' } '403': { $ref: '#/components/responses/Forbidden' } '409': { $ref: '#/components/responses/Conflict' } '413': { $ref: '#/components/responses/PayloadTooLarge' } /api/state: get: summary: Get a complete role-safe state snapshot parameters: - $ref: '#/components/parameters/Version' - $ref: '#/components/parameters/SessionToken' responses: '200': description: State view for the token role; without a token, a spectator-safe view. content: application/json: schema: $ref: '#/components/schemas/State' '401': { $ref: '#/components/responses/Unauthorized' } '413': { $ref: '#/components/responses/PayloadTooLarge' } /api/statistics: get: summary: Get match and reboot-scoped cumulative statistics parameters: - $ref: '#/components/parameters/SessionToken' responses: '200': description: Statistics only; no board cells are returned. content: application/json: schema: $ref: '#/components/schemas/Statistics' '401': { $ref: '#/components/responses/Unauthorized' } '413': { $ref: '#/components/responses/PayloadTooLarge' } components: parameters: Version: name: version in: query required: false schema: { type: integer, minimum: 0, maximum: 4294967295 } description: Last applied state version; a complete snapshot is always safe to consume. SessionToken: name: X-Session-Token in: header required: false schema: { $ref: '#/components/schemas/Token' } description: Omit only when a spectator-safe public view is intended. responses: RecoveryAccepted: description: Recovery completed or an idempotent retry observed the same completed recovery. content: application/json: schema: { $ref: '#/components/schemas/RecoveryAccepted' } CommandAccepted: description: Command accepted by the authoritative application layer. content: application/json: schema: { $ref: '#/components/schemas/CommandAccepted' } BadRequest: description: Malformed JSON or invalid input. content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } Unauthorized: description: Invalid or expired token. content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } Forbidden: description: The role may not perform this command. content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } Conflict: description: Stale game, wrong phase, unavailable slot, or invalid turn/cell state. content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } PayloadTooLarge: description: Request body or target exceeded its route bound. content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } ServerBusy: description: Bounded command queue or serializer is unavailable. content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } schemas: Token: type: string pattern: '^[0-9a-f]{32}$' description: Opaque, reboot-scoped session token. Role: type: string enum: [player1, player2, spectator, player] description: '`player` atomically assigns the first available player slot; responses always contain player1 or player2.' Mode: type: string enum: [human, bot] Phase: type: string enum: [lobby, preparing, in_progress, finished, rematch_wait] GameId: type: integer minimum: 0 maximum: 4294967295 JoinRequest: type: object required: [name, requestedRole] additionalProperties: false properties: name: { type: string, minLength: 1, maxLength: 80, description: 1–20 Unicode scalar values after server validation. } requestedRole: { $ref: '#/components/schemas/Role' } TokenRequest: type: object required: [token] additionalProperties: false properties: { token: { $ref: '#/components/schemas/Token' } } GameRequest: type: object required: [token, gameId] properties: token: { $ref: '#/components/schemas/Token' } gameId: { $ref: '#/components/schemas/GameId' } ConfigRequest: allOf: - $ref: '#/components/schemas/GameRequest' - type: object required: [mode] properties: { mode: { $ref: '#/components/schemas/Mode' } } ShotRequest: allOf: - $ref: '#/components/schemas/GameRequest' - type: object required: [x, y] properties: x: { type: integer, minimum: 0, maximum: 9 } y: { type: integer, minimum: 0, maximum: 9 } CommandAccepted: type: object required: [ok, version, gameId] properties: ok: { type: boolean, enum: [true] } version: { $ref: '#/components/schemas/GameId' } gameId: { $ref: '#/components/schemas/GameId' } SessionCreated: allOf: - $ref: '#/components/schemas/CommandAccepted' - type: object required: [token, role] properties: token: { $ref: '#/components/schemas/Token' } role: { $ref: '#/components/schemas/Role' } SessionResumed: allOf: - $ref: '#/components/schemas/CommandAccepted' - type: object required: [role] properties: { role: { $ref: '#/components/schemas/Role' } } RecoveryAccepted: type: object required: [ok, resetReason, generation] properties: ok: { type: boolean, enum: [true] } resetReason: { type: string, enum: [session_left, profile_reset, game_reset] } generation: { $ref: '#/components/schemas/GameId' } Info: type: object required: [ok, phase, gameId, version, player1Available, player2Available, player1Name, player2Name, spectatorsAvailable] properties: ok: { type: boolean, enum: [true] } phase: { $ref: '#/components/schemas/Phase' } gameId: { $ref: '#/components/schemas/GameId' } version: { $ref: '#/components/schemas/GameId' } player1Available: { type: boolean } player2Available: { type: boolean } player1Name: { type: string, maxLength: 20 } player2Name: { type: string, maxLength: 20 } spectatorsAvailable: { type: integer, minimum: 0, maximum: 8 } Health: type: object required: [ok, uptimeMs, wifiState, freeHeapBytes, minimumFreeHeapBytes, largestFreeBlockBytes, connectedClients, rejectedInput, resetReason] properties: ok: { type: boolean, enum: [true] } uptimeMs: { $ref: '#/components/schemas/GameId' } wifiState: { type: string, enum: [not_configured, connecting, connected] } freeHeapBytes: { $ref: '#/components/schemas/GameId' } minimumFreeHeapBytes: { $ref: '#/components/schemas/GameId' } largestFreeBlockBytes: { $ref: '#/components/schemas/GameId' } connectedClients: { type: integer, minimum: 0, maximum: 12 } rejectedInput: { type: integer, minimum: 0, maximum: 65535 } resetReason: { type: integer } Board: type: string pattern: '^[01234]{100}$' description: 0 unknown/water, 1 revealed ship, 2 miss, 3 hit, 4 sunk hit. MatchStatistics: type: array minItems: 4 maxItems: 4 items: { type: integer, minimum: 0 } description: '[shots, hits, misses, shipsSunk]' CumulativeStatistics: type: array minItems: 7 maxItems: 7 items: { type: integer, minimum: 0 } description: '[games, wins, losses, shipsSunk, shots, hits, misses]' State: type: object required: [type, version, gameId, phase, mode, viewer, turn, players, boards, wins, winner, statistics] properties: type: { type: string, enum: [state] } version: { $ref: '#/components/schemas/GameId' } gameId: { $ref: '#/components/schemas/GameId' } phase: { $ref: '#/components/schemas/Phase' } mode: { $ref: '#/components/schemas/Mode' } viewer: { $ref: '#/components/schemas/Role' } turn: { type: string, enum: [player1, player2] } players: type: array minItems: 2 maxItems: 2 items: { type: string, maxLength: 20 } boards: type: array minItems: 2 maxItems: 2 items: { $ref: '#/components/schemas/Board' } wins: type: array minItems: 2 maxItems: 2 items: { type: integer, minimum: 0 } winner: nullable: true type: integer enum: [0, 1] statistics: type: array minItems: 2 maxItems: 2 items: { $ref: '#/components/schemas/MatchStatistics' } Statistics: type: object required: [ok, viewer, gameId, match, cumulative] properties: ok: { type: boolean, enum: [true] } viewer: { $ref: '#/components/schemas/Role' } gameId: { $ref: '#/components/schemas/GameId' } match: type: array minItems: 2 maxItems: 2 items: { $ref: '#/components/schemas/MatchStatistics' } cumulative: type: array minItems: 2 maxItems: 2 items: { $ref: '#/components/schemas/CumulativeStatistics' } Error: type: object required: [ok, code, message, version] properties: ok: { type: boolean, enum: [false] } code: type: string enum: [MALFORMED_JSON, PAYLOAD_TOO_LARGE, INVALID_NAME, INVALID_ROLE, INVALID_MODE, INVALID_COORDINATE, UNAUTHORIZED, NO_PLAYER_SLOT, NO_SPECTATOR_SLOT, FORBIDDEN_ROLE, WRONG_PHASE, NOT_YOUR_TURN, CELL_ALREADY_SHOT, STALE_GAME, SERVER_BUSY] message: { type: string, maxLength: 80, description: Russian user-facing text. } version: { $ref: '#/components/schemas/GameId' }