feat: harden errors, recovery, and resource usage
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
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/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/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/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:
|
||||
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]
|
||||
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' } }
|
||||
Info:
|
||||
type: object
|
||||
required: [ok, phase, gameId, version, player1Available, player2Available, 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 }
|
||||
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, 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] }
|
||||
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' }
|
||||
Reference in New Issue
Block a user