Commit 37a67945 authored by Mahmoud Aglan's avatar Mahmoud Aglan

PUSHEEDDD

parent 2ffe26b9
...@@ -45,6 +45,8 @@ COPY --from=stockfish-builder /stockfish/src/*.nnue /app/ ...@@ -45,6 +45,8 @@ COPY --from=stockfish-builder /stockfish/src/*.nnue /app/
COPY --from=go-builder /app/stockfish-api /app/stockfish-api COPY --from=go-builder /app/stockfish-api /app/stockfish-api
RUN mkdir -p /app/portraits
ENV PORT=80 ENV PORT=80
ENV STOCKFISH_PATH=/usr/local/bin/stockfish ENV STOCKFISH_PATH=/usr/local/bin/stockfish
ENV POOL_SIZE=12 ENV POOL_SIZE=12
......
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAwNRFitb/O8fS9TaUoCZ/VJZUEehvdyb1EgCjPrQbTeT6TlZh
rvkzHYcGIKsgarI6wuP9aK4+rLW8SL9VP6Ey3G4CgY/Hx9ZxhoSN5N2ffZkJE1Ji
hvgkDXzSN+l4P3e422ICxuVQqozba8/o8pZo46EzgRry760i9RcR8Q8JsXysjeQ1
Q68F8JhUYt1GNQlc5/A1EHEHyv1XIMDkYQ0eart1iUf9uvU/tp6pFTNUq/UtL/BT
RaJdnShbstS7bsfZwkyRtzXUlu15z/xdCsoXbbz+GC4oV7thzZQ+eRS8sZBGTsHF
6AaNqvd3QQnbFEpUSDzK3xupVEvLw3BbwYFvxwIDAQABAoIBAB4Gr9F/yvynD/1p
A1mwxPEJ+4tSU1ENeunTuZfA+eN2PVfHcayKV2BIrzaVDxYuLKI+WC5du5qvLeNy
D7c5xa63XqKIHgbLKKBWsbWqoPQwyU397SOxLgP/pMhaDYRsgxd+Oop4GMiF6IDw
PgjQTQLtDhUTejLCFghuEDgmLE87oi4oV3m8y36Yl1gHSHLzHivk8tiJoFdd9Jw3
FdM9wPS2FcafGaT7CDhbmo8XtHgynxbjCAX6D8tOpsbhVuClseRLXMfhkai0UuO4
JhgJ4tvDoxW3G/3qZkSvL/jUr5gybUCjVAcBfE7HIvfcYKQxU+iX9R8Q7cWzFO/d
RjooSWECgYEA9DDSYxBXuOkVHq9KDWnRBWUslLk2i9nvPEL+JVPqzR6Q+uRnZzl/
j54XBd25lAcCkTDmjZTHKFroX5uvgBzHGfGZFGtoZuObfVkzK9eicupPqTe7rcN6
fTJWeJnNJsYbkGzv0jrqvBJOG+/9zGVsn7UQZvWHiS9lgKYrDz2Vx5cCgYEAyieS
3xFK+lytLgJ6pqNx6RuvEAKgouZi3sgYIxwyMSA9Yap/5po6Osj8w9X18Bvm6YYF
gok+Zx63pEB7296RrmGDxkOw5Hl/gH07Yx2hvM4et3RyvK3udOXCdcXgWN0ue/Uf
H75UZ4CLAmNALEUa9lOcB2uydVHOhXCmgPveH1ECgYAtShzLKM3MStaS8VnfsP+G
a6RgFRXrzEjVuWsfizfiQUgMcG5JM93Xyi9k9CGmNcKhIRuxqKVjc7DjgqGDNlMr
GacVpXIgmxhMoE2gVQcZHyIVNXQGn1nJfJuTFJt7FIUqPTohmLHOneqEvfcpgKor
2M4o+mLf6718pdUYp4hvEwKBgBSJhLBIz3cz5xwfgFphjHcEKvrTaYJjKXQ8m8cl
XCwFfHbpnWjODlBejt9OY1frXcAnr3Odgct0IW/8ZRjnOaGfooWH5vavKTbigiAF
qKLHxfMZT3a/rNQPa3wPiEU+4zQQqQLOkUCanIS3lJNqydxwjg9q74xfrT19Pk0o
SV6hAoGBAKfidUGqWGH3FugbgG2cm7rK54nh978brZLKglekR1RlRWKG7QpRP33v
D13y3BD1rRM3vguD2aABhwqbYVt1hjHA+mv+yDzJps08FtZIasiTRpm2mFanOD84
yKf+0/HMD2G45HzoMYdG6BdZ5HP1y4WFNfRoxjwCTnwyrDMNJhOl
-----END RSA PRIVATE KEY-----
================================================================================
STOCKFISH CHESS BOT API - COMPLETE REFERENCE
================================================================================
BASE URL: https://stockfishapi.caprover.al-arcade.com
================================================================================
ENDPOINTS
================================================================================
--------------------------------------------------------------------------------
1. GET MOVE FROM BOT
--------------------------------------------------------------------------------
POST /api/chess/move
Description:
Get a chess move from a specific bot personality. The bot will play according
to its configured skill level, depth, contempt, and blunder probability.
Response includes simulated human-like think time.
Headers:
Content-Type: application/json
Request Body:
{
"fen": "string (required) - FEN notation of the current board position",
"bot_id": "string (required) - ID of the bot to play against",
"time_limit_ms": 0 (optional, int) - time limit in ms. If 0 or omitted, uses depth-based search
}
Response (200 OK):
{
"best_move": "e2e4", // UCI move notation (from-square + to-square, e.g. e2e4, g1f3, e7e8q for promotion)
"evaluation": 0.35, // centipawn evaluation / 100. Positive = white advantage. 999.0 = white mates, -999.0 = black mates
"depth": 10, // search depth reached
"nodes": 125000, // nodes searched
"think_time_ms": 1500, // total time including artificial delay (simulates human thinking)
"pv": "e2e4 e7e5 g1f3" // principal variation (best line), space-separated UCI moves
}
Errors:
400: {"error": "invalid request body"}
400: {"error": "fen is required"}
400: {"error": "bot_id is required"}
404: {"error": "bot not found"}
500: {"error": "engine error: ..."}
Example:
curl -X POST https://stockfishapi.caprover.al-arcade.com/api/chess/move \
-H "Content-Type: application/json" \
-d '{"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "bot_id": "nour"}'
--------------------------------------------------------------------------------
2. ANALYZE POSITION
--------------------------------------------------------------------------------
POST /api/chess/analyze
Description:
Deep multi-line analysis of a position at full Stockfish strength (skill 20).
Returns multiple candidate moves ranked by evaluation.
Headers:
Content-Type: application/json
Request Body:
{
"fen": "string (required) - FEN notation of the position to analyze",
"depth": 18, (optional, int 1-30, default 18) - search depth
"lines": 3 (optional, int 1-5, default 3) - number of candidate moves to return
}
Response (200 OK):
{
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
"depth": 18,
"lines": [
{
"rank": 1,
"move": "e7e5", // best move in UCI notation
"evaluation": -0.25, // eval from engine perspective (positive = side to move is better)
"depth": 18,
"pv": "e7e5 g1f3 b8c6 ..." // full principal variation
},
{
"rank": 2,
"move": "c7c5",
"evaluation": -0.15,
"depth": 18,
"pv": "c7c5 g1f3 d7d6 ..."
}
]
}
Errors:
400: {"error": "invalid request body"}
400: {"error": "fen is required"}
500: {"error": "engine error: ..."}
Timeout: 30 seconds max per analysis request.
Example:
curl -X POST https://stockfishapi.caprover.al-arcade.com/api/chess/analyze \
-H "Content-Type: application/json" \
-d '{"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", "depth": 20, "lines": 3}'
--------------------------------------------------------------------------------
3. LIST BOTS
--------------------------------------------------------------------------------
GET /api/chess/bots
Description:
Returns all available bot personalities sorted by difficulty (easiest first).
Response (200 OK):
{
"bots": [
{
"id": "amina",
"name": "Amina",
"name_ar": "أمينة المبتدئة",
"style": "beginner",
"bio": "Just learning chess! Makes mistakes but tries her best.",
"bio_ar": "لسه بتتعلم شطرنج! بتغلط كتير بس بتحاول.",
"elo_min": 400,
"elo_max": 600,
"skill_level": 1,
"depth": 3,
"contempt": 0,
"blunder_chance": 0.30,
"think_time_min_ms": 500,
"think_time_max_ms": 2000,
"opening_book": [],
"avatar_id": "bot-amina"
},
...
]
}
Example:
curl https://stockfishapi.caprover.al-arcade.com/api/chess/bots
--------------------------------------------------------------------------------
4. POOL STATS
--------------------------------------------------------------------------------
GET /api/chess/stats
Description:
Returns the current Stockfish process pool status.
Response (200 OK):
{
"pool_alive": 6, // number of Stockfish processes currently running
"pool_idle": 4 // number of those processes currently idle (available)
}
Example:
curl https://stockfishapi.caprover.al-arcade.com/api/chess/stats
--------------------------------------------------------------------------------
5. HEALTH CHECK
--------------------------------------------------------------------------------
GET /health
Description:
Verifies the engine is operational by running a depth-1 search on the
starting position. Used by CapRover/Docker health checks.
Response (200 OK):
{
"status": "healthy",
"engine": "stockfish-18",
"pool_alive": 6,
"pool_idle": 4
}
Response (503 Service Unavailable):
{
"status": "unhealthy",
"error": "acquire process: context deadline exceeded"
}
Example:
curl https://stockfishapi.caprover.al-arcade.com/health
================================================================================
AVAILABLE BOTS (sorted by difficulty)
================================================================================
ID | Name | Arabic Name | Style | ELO | Skill | Depth | Blunder% | Think Time (ms)
--------------|-----------------|------------------|--------------|-----------|-------|-------|----------|----------------
amina | Amina | أمينة المبتدئة | beginner | 400-600 | 1 | 3 | 30% | 500-2000
tarek | Tarek | طارق المتحفظ | defensive | 800-1000 | 5 | 6 | 15% | 1000-3000
nour | Nour | نور المهاجمة | aggressive | 1000-1200 | 8 | 10 | 8% | 800-3000
omar | Omar | عمر الاستراتيجي | positional | 1200-1400 | 11 | 12 | 4% | 1500-4000
layla | Layla | ليلى المبدعة | creative | 1400-1600 | 14 | 14 | 2% | 1000-5000
ziad | Ziad | زياد الصلب | solid | 1600-1800 | 17 | 16 | 1% | 2000-6000
grandmaster | Grandmaster Bot | الجراند ماستر | near_perfect | 2000-2200 | 20 | 20 | 0% | 3000-8000
================================================================================
BOT BEHAVIOR DETAILS
================================================================================
BLUNDER MECHANISM:
Each move request, the bot rolls against its blunder_chance probability.
If it "blunders", the engine searches at depth=1 with skill_level=0,
producing a weak/random move. Otherwise it plays at its configured strength.
THINK TIME SIMULATION:
After the engine returns a move, the API adds artificial delay to simulate
human-like thinking. The delay is random between think_time_min and think_time_max.
Total response time = engine_time + artificial_delay.
If the client disconnects (context cancelled), the delay is aborted.
CONTEMPT:
Positive contempt = bot plays more aggressively, avoids draws.
Negative contempt = bot is happy to draw, plays defensively.
Range: -100 to 100.
SKILL LEVEL:
Stockfish's internal skill parameter (0-20).
0 = weakest, introduces random errors.
20 = full strength, no artificial weakening.
OPENING BOOKS (metadata only, not enforced by engine):
Listed per bot for frontend display. The engine does not use opening books;
it calculates from the given FEN position directly.
AVATAR IDs:
Each bot has an avatar_id field for frontend use (e.g. "bot-amina", "bot-nour").
Map these to your avatar image assets.
================================================================================
MOVE NOTATION
================================================================================
All moves use UCI (Universal Chess Interface) long algebraic notation:
- Normal move: source_square + destination_square (e.g. "e2e4", "g1f3")
- Pawn promotion: source + destination + piece_letter (e.g. "e7e8q" for queen promotion)
- Castling: king's start + king's end (e.g. "e1g1" for white kingside, "e1c1" for queenside)
- En passant: normal pawn capture notation (e.g. "e5d6")
Piece letters for promotion: q=queen, r=rook, b=bishop, n=knight
================================================================================
FEN (Forsyth-Edwards Notation) FORMAT
================================================================================
A FEN string describes a complete board position in a single line:
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
| | | | | |
| | | | | +-- fullmove number
| | | | +----- halfmove clock (50-move rule)
| | | +---------- en passant target square ("-" if none)
| | +----------------- castling availability (KQkq or "-")
| +--------------------------- active color: "w" or "b"
+------------------------------------- piece placement (rank 8 to rank 1, "/" separated)
Piece letters: K=king, Q=queen, R=rook, B=bishop, N=knight, P=pawn
Uppercase = white, lowercase = black
Numbers = consecutive empty squares
Starting position FEN:
rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
================================================================================
EVALUATION VALUES
================================================================================
The "evaluation" field in responses:
- Measured in pawns (centipawns / 100)
- Positive = white advantage
- Negative = black advantage
- +0.50 means white is half a pawn ahead
- +999.0 = white has forced checkmate
- -999.0 = black has forced checkmate
- Values near 0.0 = roughly equal position
================================================================================
RATE LIMITING
================================================================================
- 60 requests per minute per IP address
- Applies to all endpoints
- X-Forwarded-For header is respected (for reverse proxy setups)
- When exceeded: 429 Too Many Requests {"error": "rate limit exceeded"}
================================================================================
CORS
================================================================================
- Access-Control-Allow-Origin: * (all origins allowed)
- Allowed Methods: GET, POST, OPTIONS
- Allowed Headers: Content-Type, Authorization, apikey
- Preflight cache: 86400 seconds (24 hours)
================================================================================
ADMIN PANEL
================================================================================
URL: https://stockfishapi.caprover.al-arcade.com/admin
Login: username "admin", password "Alarcade123#"
Features:
- Dashboard: live pool stats, bot count, request metrics
- Bot management: create, edit, delete bots at runtime
- Pool monitoring: alive/idle process counts
- Test Move: test any bot with a custom FEN position
- Request Logs: last 100 requests with method, path, status, duration, IP
- Settings: view/modify port, pool size, idle timeout
Note: Runtime changes (bot edits, new bots) persist only until container restart.
Core bot definitions are compiled into the binary.
================================================================================
INFRASTRUCTURE
================================================================================
- Engine: Stockfish 18 (compiled from source, NNUE enabled)
- Runtime: Go 1.22, single static binary
- Process Pool: 12 Stockfish processes (configurable via POOL_SIZE env)
- Pre-warmed: half the pool at startup for instant first requests
- Idle Reaper: kills processes unused for 300s (configurable via IDLE_TIMEOUT_SEC)
- Platform: Docker on CapRover (Ubuntu 22.04 base)
- Architecture: x86-64-sse41-popcnt (broad server CPU compatibility)
- Health Check: every 30s, 10s timeout, 3 retries, 15s start period
- Port: 80 inside container (CapRover handles HTTPS/reverse proxy)
================================================================================
ENVIRONMENT VARIABLES
================================================================================
PORT=80 # HTTP listen port
STOCKFISH_PATH=/usr/local/bin/stockfish # path to Stockfish binary
POOL_SIZE=12 # max concurrent Stockfish processes
IDLE_TIMEOUT_SEC=300 # kill idle processes after this many seconds
================================================================================
INTEGRATION EXAMPLES
================================================================================
--- JavaScript/Fetch ---
// Get a move from "nour" bot
const response = await fetch('https://stockfishapi.caprover.al-arcade.com/api/chess/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
bot_id: 'nour'
})
});
const data = await response.json();
console.log(data.best_move); // e.g. "e7e5"
// Analyze a position
const analysis = await fetch('https://stockfishapi.caprover.al-arcade.com/api/chess/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
depth: 20,
lines: 3
})
});
const result = await analysis.json();
result.lines.forEach(line => {
console.log(`#${line.rank}: ${line.move} (eval: ${line.evaluation})`);
});
// List all bots
const botsResp = await fetch('https://stockfishapi.caprover.al-arcade.com/api/chess/bots');
const botsData = await botsResp.json();
botsData.bots.forEach(bot => {
console.log(`${bot.name} (${bot.elo_min}-${bot.elo_max} ELO) - ${bot.style}`);
});
--- Python ---
import requests
# Get move
resp = requests.post('https://stockfishapi.caprover.al-arcade.com/api/chess/move', json={
'fen': 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
'bot_id': 'omar'
})
move = resp.json()
print(f"Best move: {move['best_move']}, Eval: {move['evaluation']}")
# Analyze
resp = requests.post('https://stockfishapi.caprover.al-arcade.com/api/chess/analyze', json={
'fen': 'r1bqkbnr/pppppppp/2n5/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2',
'depth': 22,
'lines': 5
})
for line in resp.json()['lines']:
print(f"#{line['rank']}: {line['move']} (eval {line['evaluation']:.2f})")
--- cURL ---
# Quick move
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/chess/move \
-H "Content-Type: application/json" \
-d '{"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","bot_id":"amina"}' | jq .
# Health check
curl -s https://stockfishapi.caprover.al-arcade.com/health | jq .
================================================================================
TYPICAL GAME FLOW (for client integration)
================================================================================
1. GET /api/chess/bots -> show bot selection to user
2. User picks a bot (e.g. "nour") and starts a game
3. Client tracks FEN locally (or uses a chess library)
4. When it's the bot's turn:
POST /api/chess/move with current FEN and bot_id
5. Apply the returned best_move to the board
6. Repeat from step 4 until checkmate/stalemate/draw
7. Optionally: POST /api/chess/analyze for post-game analysis
Important notes for client implementation:
- Always send the FULL FEN including castling rights, en passant, move counters
- The API does NOT validate if the FEN is a legal position
- The API does NOT track game state - it's stateless (one move per request)
- Handle the artificial think_time_ms for UX (show "thinking..." animation)
- Handle 429 rate limit gracefully (retry after 1 second)
- The response time can be up to 8000ms+ for grandmaster bot (due to simulated think time)
================================================================================
ERROR HANDLING
================================================================================
All errors return JSON with an "error" field:
400 Bad Request - malformed JSON, missing required fields
404 Not Found - invalid bot_id
429 Too Many Requests - rate limit exceeded (60/min/IP)
500 Internal Server Error - Stockfish process crash, pool exhaustion, timeout
503 Service Unavailable - health check failed (engine not responding)
Recommended retry strategy:
- 429: wait 1-2 seconds, retry
- 500: wait 2-5 seconds, retry up to 3 times
- 503: service is down, alert user
================================================================================
RESPONSE TIME EXPECTATIONS
================================================================================
Bot | Typical Response Time (includes simulated think time)
----------------|------------------------------------------------------
amina | 500ms - 2000ms
tarek | 1000ms - 3000ms
nour | 800ms - 3000ms
omar | 1500ms - 4000ms
layla | 1000ms - 5000ms
ziad | 2000ms - 6000ms
grandmaster | 3000ms - 8000ms
Analysis endpoint: 1-30 seconds depending on depth (no artificial delay).
Health check: < 5 seconds.
Set client timeouts accordingly (recommend 15s for moves, 35s for analysis).
================================================================================
STOCKFISH CHESS BOT API - COMPLETE REFERENCE
================================================================================
BASE URL: https://stockfishapi.caprover.al-arcade.com
================================================================================
ENDPOINTS
================================================================================
--------------------------------------------------------------------------------
1. GET MOVE FROM BOT
--------------------------------------------------------------------------------
POST /api/chess/move
Description:
Get a chess move from a specific bot personality. The bot will play according
to its configured skill level, depth, contempt, and blunder probability.
Response includes simulated human-like think time.
Headers:
Content-Type: application/json
Request Body:
{
"fen": "string (required) - FEN notation of the current board position",
"bot_id": "string (required) - ID of the bot to play against",
"time_limit_ms": 0 (optional, int) - time limit in ms. If 0 or omitted, uses depth-based search
}
Response (200 OK):
{
"best_move": "e2e4", // UCI move notation (from-square + to-square, e.g. e2e4, g1f3, e7e8q for promotion)
"evaluation": 0.35, // centipawn evaluation / 100. Positive = white advantage. 999.0 = white mates, -999.0 = black mates
"depth": 10, // search depth reached
"nodes": 125000, // nodes searched
"think_time_ms": 1500, // total time including artificial delay (simulates human thinking)
"pv": "e2e4 e7e5 g1f3" // principal variation (best line), space-separated UCI moves
}
Errors:
400: {"error": "invalid request body"}
400: {"error": "fen is required"}
400: {"error": "bot_id is required"}
404: {"error": "bot not found"}
500: {"error": "engine error: ..."}
Example:
curl -X POST https://stockfishapi.caprover.al-arcade.com/api/chess/move \
-H "Content-Type: application/json" \
-d '{"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "bot_id": "nour"}'
--------------------------------------------------------------------------------
2. ANALYZE POSITION
--------------------------------------------------------------------------------
POST /api/chess/analyze
Description:
Deep multi-line analysis of a position at full Stockfish strength (skill 20).
Returns multiple candidate moves ranked by evaluation.
Headers:
Content-Type: application/json
Request Body:
{
"fen": "string (required) - FEN notation of the position to analyze",
"depth": 18, (optional, int 1-30, default 18) - search depth
"lines": 3 (optional, int 1-5, default 3) - number of candidate moves to return
}
Response (200 OK):
{
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
"depth": 18,
"lines": [
{
"rank": 1,
"move": "e7e5", // best move in UCI notation
"evaluation": -0.25, // eval from engine perspective (positive = side to move is better)
"depth": 18,
"pv": "e7e5 g1f3 b8c6 ..." // full principal variation
},
{
"rank": 2,
"move": "c7c5",
"evaluation": -0.15,
"depth": 18,
"pv": "c7c5 g1f3 d7d6 ..."
}
]
}
Errors:
400: {"error": "invalid request body"}
400: {"error": "fen is required"}
500: {"error": "engine error: ..."}
Timeout: 30 seconds max per analysis request.
Example:
curl -X POST https://stockfishapi.caprover.al-arcade.com/api/chess/analyze \
-H "Content-Type: application/json" \
-d '{"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", "depth": 20, "lines": 3}'
--------------------------------------------------------------------------------
3. LIST BOTS
--------------------------------------------------------------------------------
GET /api/chess/bots
Description:
Returns all available bot personalities sorted by difficulty (easiest first).
Response (200 OK):
{
"bots": [
{
"id": "amina",
"name": "Amina",
"name_ar": "أمينة المبتدئة",
"style": "beginner",
"style_ar": "مبتدئة",
"bio": "Just learning chess! Makes mistakes but tries her best.",
"bio_ar": "لسه بتتعلم شطرنج! بتغلط كتير بس بتحاول.",
"elo_min": 400,
"elo_max": 600,
"skill_level": 1,
"depth": 3,
"contempt": 0,
"blunder_chance": 0.30,
"think_time_min_ms": 500,
"think_time_max_ms": 2000,
"opening_book": [],
"avatar_id": "bot-amina",
"portrait_url": "/portraits/amina.png"
},
...
]
}
Example:
curl https://stockfishapi.caprover.al-arcade.com/api/chess/bots
--------------------------------------------------------------------------------
4. POOL STATS
--------------------------------------------------------------------------------
GET /api/chess/stats
Description:
Returns the current Stockfish process pool status.
Response (200 OK):
{
"pool_alive": 6, // number of Stockfish processes currently running
"pool_idle": 4 // number of those processes currently idle (available)
}
Example:
curl https://stockfishapi.caprover.al-arcade.com/api/chess/stats
--------------------------------------------------------------------------------
5. HEALTH CHECK
--------------------------------------------------------------------------------
GET /health
Description:
Verifies the engine is operational by running a depth-1 search on the
starting position. Used by CapRover/Docker health checks.
Response (200 OK):
{
"status": "healthy",
"engine": "stockfish-18",
"pool_alive": 6,
"pool_idle": 4
}
Response (503 Service Unavailable):
{
"status": "unhealthy",
"error": "acquire process: context deadline exceeded"
}
Example:
curl https://stockfishapi.caprover.al-arcade.com/health
================================================================================
AVAILABLE BOTS (sorted by difficulty)
================================================================================
ID | Name | Arabic Name | Style | Style (AR) | ELO | Skill | Depth | Blunder% | Think Time (ms) | Portrait
--------------|-----------------|------------------|--------------|-------------|-----------|-------|-------|----------|-----------------|------------------
amina | Amina | أمينة المبتدئة | beginner | مبتدئة | 400-600 | 1 | 3 | 30% | 500-2000 | /portraits/amina.png
tarek | Tarek | طارق المتحفظ | defensive | دفاعي | 800-1000 | 5 | 6 | 15% | 1000-3000 | /portraits/tarek.png
nour | Nour | نور المهاجمة | aggressive | هجومية | 1000-1200 | 8 | 10 | 8% | 800-3000 | /portraits/nour.png
omar | Omar | عمر الاستراتيجي | positional | استراتيجي | 1200-1400 | 11 | 12 | 4% | 1500-4000 | /portraits/omar.png
layla | Layla | ليلى المبدعة | creative | إبداعية | 1400-1600 | 14 | 14 | 2% | 1000-5000 | /portraits/layla.png
ziad | Ziad | زياد الصلب | solid | صلب | 1600-1800 | 17 | 16 | 1% | 2000-6000 | /portraits/ziad.png
grandmaster | Grandmaster Bot | الجراند ماستر | near_perfect | شبه مثالي | 2000-2200 | 20 | 20 | 0% | 3000-8000 | /portraits/grandmaster.png
================================================================================
BOT BEHAVIOR DETAILS
================================================================================
BLUNDER MECHANISM:
Each move request, the bot rolls against its blunder_chance probability.
If it "blunders", the engine searches at depth=1 with skill_level=0,
producing a weak/random move. Otherwise it plays at its configured strength.
THINK TIME SIMULATION:
After the engine returns a move, the API adds artificial delay to simulate
human-like thinking. The delay is random between think_time_min and think_time_max.
Total response time = engine_time + artificial_delay.
If the client disconnects (context cancelled), the delay is aborted.
CONTEMPT:
Positive contempt = bot plays more aggressively, avoids draws.
Negative contempt = bot is happy to draw, plays defensively.
Range: -100 to 100.
SKILL LEVEL:
Stockfish's internal skill parameter (0-20).
0 = weakest, introduces random errors.
20 = full strength, no artificial weakening.
OPENING BOOKS (metadata only, not enforced by engine):
Listed per bot for frontend display. The engine does not use opening books;
it calculates from the given FEN position directly.
AVATAR IDs:
Each bot has an avatar_id field for frontend use (e.g. "bot-amina", "bot-nour").
Map these to your avatar image assets.
PORTRAITS:
Each bot has a portrait_url field pointing to a 512x512 pixel image.
Portraits are served publicly at: https://stockfishapi.caprover.al-arcade.com/portraits/{bot_id}.png
Supported formats: PNG, JPG, WebP.
Upload via admin panel: /admin/bots/edit/{id} -> Portrait upload form.
Recommended dimensions: 512x512 pixels (square).
These are character portraits/avatars for display in the chess UI.
STYLE LABELS (Arabic):
Each bot has a "style_ar" field with the Arabic translation of its play style.
Use this for bilingual (EN/AR) UI display.
Examples: "مبتدئة" (beginner), "دفاعي" (defensive), "هجومية" (aggressive),
"استراتيجي" (positional), "إبداعية" (creative), "صلب" (solid),
"شبه مثالي" (near_perfect).
================================================================================
MOVE NOTATION
================================================================================
All moves use UCI (Universal Chess Interface) long algebraic notation:
- Normal move: source_square + destination_square (e.g. "e2e4", "g1f3")
- Pawn promotion: source + destination + piece_letter (e.g. "e7e8q" for queen promotion)
- Castling: king's start + king's end (e.g. "e1g1" for white kingside, "e1c1" for queenside)
- En passant: normal pawn capture notation (e.g. "e5d6")
Piece letters for promotion: q=queen, r=rook, b=bishop, n=knight
================================================================================
FEN (Forsyth-Edwards Notation) FORMAT
================================================================================
A FEN string describes a complete board position in a single line:
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
| | | | | |
| | | | | +-- fullmove number
| | | | +----- halfmove clock (50-move rule)
| | | +---------- en passant target square ("-" if none)
| | +----------------- castling availability (KQkq or "-")
| +--------------------------- active color: "w" or "b"
+------------------------------------- piece placement (rank 8 to rank 1, "/" separated)
Piece letters: K=king, Q=queen, R=rook, B=bishop, N=knight, P=pawn
Uppercase = white, lowercase = black
Numbers = consecutive empty squares
Starting position FEN:
rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
================================================================================
EVALUATION VALUES
================================================================================
The "evaluation" field in responses:
- Measured in pawns (centipawns / 100)
- Positive = white advantage
- Negative = black advantage
- +0.50 means white is half a pawn ahead
- +999.0 = white has forced checkmate
- -999.0 = black has forced checkmate
- Values near 0.0 = roughly equal position
================================================================================
RATE LIMITING
================================================================================
- 60 requests per minute per IP address
- Applies to all endpoints
- X-Forwarded-For header is respected (for reverse proxy setups)
- When exceeded: 429 Too Many Requests {"error": "rate limit exceeded"}
================================================================================
CORS
================================================================================
- Access-Control-Allow-Origin: * (all origins allowed)
- Allowed Methods: GET, POST, OPTIONS
- Allowed Headers: Content-Type, Authorization, apikey
- Preflight cache: 86400 seconds (24 hours)
================================================================================
MANAGEMENT API (Full Control)
================================================================================
Base: https://stockfishapi.caprover.al-arcade.com/api/manage
Authentication:
All /api/manage/* endpoints require an API key via header:
X-API-Key: sk-alarc-stockfish-mgmt-2024
OR:
Authorization: Bearer sk-alarc-stockfish-mgmt-2024
Rate Limiting: None on management endpoints (auth-gated).
--------------------------------------------------------------------------------
SYSTEM INFO
--------------------------------------------------------------------------------
GET /api/manage/info
Returns full system overview + all available endpoint list.
Response:
{
"engine": "stockfish-18",
"version": "1.0.0",
"bot_count": 7,
"pool": { "alive": 6, "idle": 4, "max_size": 12, "idle_timeout": 300 },
"settings": { "port": "80", "stockfish_path": "/usr/local/bin/stockfish" },
"endpoints": { ... all endpoints listed ... }
}
--------------------------------------------------------------------------------
BOT CRUD
--------------------------------------------------------------------------------
GET /api/manage/bots
List all bots with count.
GET /api/manage/bots/{id}
Get a single bot by ID.
POST /api/manage/bots
Create a new bot. Send full bot JSON in body.
Required: id, name, style
Returns 201 on success, 409 if ID already exists.
Body:
{
"id": "yasmin",
"name": "Yasmin",
"name_ar": "ياسمين",
"style": "tricky",
"style_ar": "ماكرة",
"bio": "Sets traps and waits for you to fall in.",
"bio_ar": "بتحط فخاخ وبتستنى تقع فيها.",
"elo_min": 1100,
"elo_max": 1300,
"skill_level": 9,
"depth": 11,
"contempt": 20,
"blunder_chance": 0.06,
"think_time_min_ms": 1000,
"think_time_max_ms": 3500,
"opening_book": ["sicilian", "french"],
"avatar_id": "bot-yasmin",
"portrait_url": "/portraits/yasmin.png"
}
PATCH /api/manage/bots/{id}
Partial update. Only send fields you want to change.
Body: { "skill_level": 12, "blunder_chance": 0.05, "style_ar": "ذكية" }
PUT /api/manage/bots/{id}
Full replace. Overwrites entire bot with new data.
Body: same as POST but ID comes from URL.
DELETE /api/manage/bots/{id}
Delete a bot and its portrait files.
--------------------------------------------------------------------------------
BULK OPERATIONS
--------------------------------------------------------------------------------
POST /api/manage/bots/bulk
Create multiple bots at once.
Body: [ {bot1}, {bot2}, ... ]
Response: { "created": ["id1","id2"], "skipped": ["id3 (already exists)"] }
DELETE /api/manage/bots/bulk
Delete multiple bots at once.
Body: { "ids": ["amina", "tarek"] }
Response: { "deleted": ["amina","tarek"], "not_found": [] }
GET /api/manage/bots/export
Download all bots as JSON file (Content-Disposition: attachment).
POST /api/manage/bots/import?overwrite=true
Import bots from JSON array. With overwrite=true, existing bots are replaced.
Body: [ {bot1}, {bot2}, ... ]
Response: { "imported": ["id1"], "skipped": ["id2 (exists)"], "total": 8 }
--------------------------------------------------------------------------------
PORTRAIT MANAGEMENT
--------------------------------------------------------------------------------
POST /api/manage/bots/{id}/portrait
Upload a portrait image (512x512 px).
Content-Type: multipart/form-data
Field name: "portrait"
Accepted: .png, .jpg, .jpeg, .webp (max 10MB)
curl example:
curl -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina/portrait \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-F "portrait=@amina_512x512.png"
Response: { "status": "uploaded", "bot_id": "amina", "portrait_url": "/portraits/amina.png", "size_bytes": 245000 }
DELETE /api/manage/bots/{id}/portrait
Delete all portrait files for a bot.
Response: { "status": "deleted", "bot_id": "amina" }
Portraits are served publicly (no auth):
GET https://stockfishapi.caprover.al-arcade.com/portraits/{bot_id}.png
--------------------------------------------------------------------------------
ENGINE / POOL
--------------------------------------------------------------------------------
GET /api/manage/pool
Full pool stats with utilization percentage.
Response:
{
"pool_alive": 6,
"pool_idle": 4,
"pool_max_size": 12,
"pool_utilization": "16.7%",
"idle_timeout_sec": 300
}
POST /api/manage/test-move
Test the engine directly. Supports raw mode (custom params) or bot mode.
Bot mode:
{ "fen": "...", "bot_id": "nour" }
Raw mode (bypass bot config, use custom engine params):
{
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
"raw_mode": true,
"depth": 25,
"skill_level": 20,
"contempt": 0,
"time_limit_ms": 5000,
"multi_pv": 3
}
Response:
{
"best_move": "e7e5",
"evaluation": -0.12,
"depth": 25,
"nodes": 5000000,
"pv": "e7e5 g1f3 b8c6",
"engine_time_ms": 3200,
"request": { "fen": "...", "depth": 25, "skill_level": 20, "contempt": 0, "multi_pv": 3 }
}
POST /api/manage/analyze
Deep analysis (up to depth 40, up to 10 lines).
{ "fen": "...", "depth": 30, "lines": 5 }
Response: { "fen": "...", "depth": 30, "lines": [...], "engine_time_ms": 12000 }
--------------------------------------------------------------------------------
SETTINGS
--------------------------------------------------------------------------------
GET /api/manage/settings
{ "port": "80", "pool_size": 12, "idle_timeout_sec": 300, "stockfish_path": "/usr/local/bin/stockfish" }
PATCH /api/manage/settings
Update settings (partial). Some require restart.
Body: { "pool_size": 16, "idle_timeout_sec": 600 }
Response: { "status": "updated", "settings": {...}, "note": "some changes require restart" }
--------------------------------------------------------------------------------
LOGS
--------------------------------------------------------------------------------
GET /api/manage/logs?limit=50
Get recent request logs (max 100).
Response:
{
"logs": [
{ "Timestamp": "2024-01-15 14:30:22", "Method": "POST", "Path": "/api/chess/move", "Status": 200, "Duration": "45ms", "IP": "1.2.3.4" },
...
],
"count": 50
}
================================================================================
MANAGEMENT API - QUICK REFERENCE (curl examples)
================================================================================
# Auth header (use in all requests)
AUTH="-H 'X-API-Key: sk-alarc-stockfish-mgmt-2024'"
# System info
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/info \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# List bots
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/bots \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Get single bot
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/bots/nour \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Create bot
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/bots \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"id":"yasmin","name":"Yasmin","name_ar":"ياسمين","style":"tricky","style_ar":"ماكرة","skill_level":9,"depth":11,"contempt":20,"blunder_chance":0.06,"think_time_min_ms":1000,"think_time_max_ms":3500,"elo_min":1100,"elo_max":1300}' | jq .
# Update bot (partial)
curl -s -X PATCH https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"skill_level":2,"blunder_chance":0.25}' | jq .
# Replace bot (full)
curl -s -X PUT https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"name":"Amina","name_ar":"أمينة","style":"beginner","style_ar":"مبتدئة","skill_level":1,"depth":3}' | jq .
# Delete bot
curl -s -X DELETE https://stockfishapi.caprover.al-arcade.com/api/manage/bots/yasmin \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Bulk create
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/bots/bulk \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '[{"id":"bot1","name":"Bot One","style":"test","skill_level":5,"depth":5},{"id":"bot2","name":"Bot Two","style":"test","skill_level":10,"depth":10}]' | jq .
# Bulk delete
curl -s -X DELETE https://stockfishapi.caprover.al-arcade.com/api/manage/bots/bulk \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"ids":["bot1","bot2"]}' | jq .
# Export all bots (download)
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/bots/export \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" -o bots_backup.json
# Import bots (with overwrite)
curl -s -X POST "https://stockfishapi.caprover.al-arcade.com/api/manage/bots/import?overwrite=true" \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d @bots_backup.json | jq .
# Upload portrait
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina/portrait \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-F "portrait=@amina.png" | jq .
# Delete portrait
curl -s -X DELETE https://stockfishapi.caprover.al-arcade.com/api/manage/bots/amina/portrait \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Pool stats
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/pool \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Test move (bot mode)
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/test-move \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1","bot_id":"grandmaster"}' | jq .
# Test move (raw mode - custom engine params)
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/test-move \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1","raw_mode":true,"depth":30,"skill_level":20,"contempt":0}' | jq .
# Deep analysis
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/manage/analyze \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1","depth":30,"lines":5}' | jq .
# Get settings
curl -s https://stockfishapi.caprover.al-arcade.com/api/manage/settings \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
# Update settings
curl -s -X PATCH https://stockfishapi.caprover.al-arcade.com/api/manage/settings \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" \
-H "Content-Type: application/json" \
-d '{"pool_size":16,"idle_timeout_sec":600}' | jq .
# View logs
curl -s "https://stockfishapi.caprover.al-arcade.com/api/manage/logs?limit=20" \
-H "X-API-Key: sk-alarc-stockfish-mgmt-2024" | jq .
================================================================================
ADMIN PANEL
================================================================================
URL: https://stockfishapi.caprover.al-arcade.com/admin
Login: username "admin", password "Alarcade123#"
Features:
- Dashboard: live pool stats, bot count, request metrics
- Bot management: create, edit, delete bots at runtime
- Pool monitoring: alive/idle process counts
- Test Move: test any bot with a custom FEN position
- Request Logs: last 100 requests with method, path, status, duration, IP
- Settings: view/modify port, pool size, idle timeout
Note: Runtime changes (bot edits, new bots) persist only until container restart.
Core bot definitions are compiled into the binary.
================================================================================
INFRASTRUCTURE
================================================================================
- Engine: Stockfish 18 (compiled from source, NNUE enabled)
- Runtime: Go 1.22, single static binary
- Process Pool: 12 Stockfish processes (configurable via POOL_SIZE env)
- Pre-warmed: half the pool at startup for instant first requests
- Idle Reaper: kills processes unused for 300s (configurable via IDLE_TIMEOUT_SEC)
- Platform: Docker on CapRover (Ubuntu 22.04 base)
- Architecture: x86-64-sse41-popcnt (broad server CPU compatibility)
- Health Check: every 30s, 10s timeout, 3 retries, 15s start period
- Port: 80 inside container (CapRover handles HTTPS/reverse proxy)
================================================================================
ENVIRONMENT VARIABLES
================================================================================
PORT=80 # HTTP listen port
STOCKFISH_PATH=/usr/local/bin/stockfish # path to Stockfish binary
POOL_SIZE=12 # max concurrent Stockfish processes
IDLE_TIMEOUT_SEC=300 # kill idle processes after this many seconds
================================================================================
INTEGRATION EXAMPLES
================================================================================
--- JavaScript/Fetch ---
// Get a move from "nour" bot
const response = await fetch('https://stockfishapi.caprover.al-arcade.com/api/chess/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
bot_id: 'nour'
})
});
const data = await response.json();
console.log(data.best_move); // e.g. "e7e5"
// Analyze a position
const analysis = await fetch('https://stockfishapi.caprover.al-arcade.com/api/chess/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
depth: 20,
lines: 3
})
});
const result = await analysis.json();
result.lines.forEach(line => {
console.log(`#${line.rank}: ${line.move} (eval: ${line.evaluation})`);
});
// List all bots
const botsResp = await fetch('https://stockfishapi.caprover.al-arcade.com/api/chess/bots');
const botsData = await botsResp.json();
botsData.bots.forEach(bot => {
console.log(`${bot.name} (${bot.elo_min}-${bot.elo_max} ELO) - ${bot.style}`);
});
--- Python ---
import requests
# Get move
resp = requests.post('https://stockfishapi.caprover.al-arcade.com/api/chess/move', json={
'fen': 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
'bot_id': 'omar'
})
move = resp.json()
print(f"Best move: {move['best_move']}, Eval: {move['evaluation']}")
# Analyze
resp = requests.post('https://stockfishapi.caprover.al-arcade.com/api/chess/analyze', json={
'fen': 'r1bqkbnr/pppppppp/2n5/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2',
'depth': 22,
'lines': 5
})
for line in resp.json()['lines']:
print(f"#{line['rank']}: {line['move']} (eval {line['evaluation']:.2f})")
--- cURL ---
# Quick move
curl -s -X POST https://stockfishapi.caprover.al-arcade.com/api/chess/move \
-H "Content-Type: application/json" \
-d '{"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","bot_id":"amina"}' | jq .
# Health check
curl -s https://stockfishapi.caprover.al-arcade.com/health | jq .
================================================================================
TYPICAL GAME FLOW (for client integration)
================================================================================
1. GET /api/chess/bots -> show bot selection to user
2. User picks a bot (e.g. "nour") and starts a game
3. Client tracks FEN locally (or uses a chess library)
4. When it's the bot's turn:
POST /api/chess/move with current FEN and bot_id
5. Apply the returned best_move to the board
6. Repeat from step 4 until checkmate/stalemate/draw
7. Optionally: POST /api/chess/analyze for post-game analysis
Important notes for client implementation:
- Always send the FULL FEN including castling rights, en passant, move counters
- The API does NOT validate if the FEN is a legal position
- The API does NOT track game state - it's stateless (one move per request)
- Handle the artificial think_time_ms for UX (show "thinking..." animation)
- Handle 429 rate limit gracefully (retry after 1 second)
- The response time can be up to 8000ms+ for grandmaster bot (due to simulated think time)
================================================================================
ERROR HANDLING
================================================================================
All errors return JSON with an "error" field:
400 Bad Request - malformed JSON, missing required fields
404 Not Found - invalid bot_id
429 Too Many Requests - rate limit exceeded (60/min/IP)
500 Internal Server Error - Stockfish process crash, pool exhaustion, timeout
503 Service Unavailable - health check failed (engine not responding)
Recommended retry strategy:
- 429: wait 1-2 seconds, retry
- 500: wait 2-5 seconds, retry up to 3 times
- 503: service is down, alert user
================================================================================
RESPONSE TIME EXPECTATIONS
================================================================================
Bot | Typical Response Time (includes simulated think time)
----------------|------------------------------------------------------
amina | 500ms - 2000ms
tarek | 1000ms - 3000ms
nour | 800ms - 3000ms
omar | 1500ms - 4000ms
layla | 1000ms - 5000ms
ziad | 2000ms - 6000ms
grandmaster | 3000ms - 8000ms
Analysis endpoint: 1-30 seconds depending on depth (no artificial delay).
Health check: < 5 seconds.
Set client timeouts accordingly (recommend 15s for moves, 35s for analysis).
...@@ -41,6 +41,9 @@ func main() { ...@@ -41,6 +41,9 @@ func main() {
r.Get("/api/chess/stats", api.HandleStats) r.Get("/api/chess/stats", api.HandleStats)
r.Get("/health", api.HandleHealth) r.Get("/health", api.HandleHealth)
// Management API
api.RegisterManagementRoutes(r)
// Admin panel // Admin panel
admin.RegisterRoutes(r) admin.RegisterRoutes(r)
......
...@@ -5,7 +5,10 @@ import ( ...@@ -5,7 +5,10 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"html/template" "html/template"
"io"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
...@@ -18,6 +21,8 @@ import ( ...@@ -18,6 +21,8 @@ import (
"stockfish-api/web" "stockfish-api/web"
) )
const PortraitDir = "/app/portraits"
// Template functions // Template functions
var funcMap = template.FuncMap{ var funcMap = template.FuncMap{
"mul": func(a, b interface{}) float64 { "mul": func(a, b interface{}) float64 {
...@@ -181,7 +186,7 @@ func AddLog(method, path string, status int, duration time.Duration, ip string) ...@@ -181,7 +186,7 @@ func AddLog(method, path string, status int, duration time.Duration, ip string)
} }
} }
func getRecentLogs() []LogEntry { func GetRecentLogs() []LogEntry {
logMu.Lock() logMu.Lock()
defer logMu.Unlock() defer logMu.Unlock()
...@@ -314,6 +319,24 @@ func HandleBotEdit(w http.ResponseWriter, r *http.Request) { ...@@ -314,6 +319,24 @@ func HandleBotEdit(w http.ResponseWriter, r *http.Request) {
// POST - update bot parameters // POST - update bot parameters
r.ParseForm() r.ParseForm()
if v := strings.TrimSpace(r.FormValue("name")); v != "" {
bot.Name = v
}
if v := r.FormValue("name_ar"); v != "" {
bot.NameAr = v
}
if v := strings.TrimSpace(r.FormValue("style")); v != "" {
bot.Style = v
}
if v := r.FormValue("style_ar"); v != "" {
bot.StyleAr = v
}
if v := r.FormValue("bio"); v != "" {
bot.Bio = v
}
if v := r.FormValue("bio_ar"); v != "" {
bot.BioAr = v
}
if v, err := strconv.Atoi(r.FormValue("skill_level")); err == nil { if v, err := strconv.Atoi(r.FormValue("skill_level")); err == nil {
bot.SkillLevel = v bot.SkillLevel = v
} }
...@@ -380,7 +403,13 @@ func HandleBotCreate(w http.ResponseWriter, r *http.Request) { ...@@ -380,7 +403,13 @@ func HandleBotCreate(w http.ResponseWriter, r *http.Request) {
bot := &bots.BotPersonality{ bot := &bots.BotPersonality{
ID: id, ID: id,
Name: name, Name: name,
NameAr: strings.TrimSpace(r.FormValue("name_ar")),
Style: style, Style: style,
StyleAr: strings.TrimSpace(r.FormValue("style_ar")),
Bio: strings.TrimSpace(r.FormValue("bio")),
BioAr: strings.TrimSpace(r.FormValue("bio_ar")),
AvatarID: strings.TrimSpace(r.FormValue("avatar_id")),
PortraitURL: "/portraits/" + id + ".png",
} }
if v, err := strconv.Atoi(r.FormValue("skill_level")); err == nil { if v, err := strconv.Atoi(r.FormValue("skill_level")); err == nil {
...@@ -483,7 +512,7 @@ func HandleSettings(w http.ResponseWriter, r *http.Request) { ...@@ -483,7 +512,7 @@ func HandleSettings(w http.ResponseWriter, r *http.Request) {
} }
func HandleLogs(w http.ResponseWriter, r *http.Request) { func HandleLogs(w http.ResponseWriter, r *http.Request) {
logs := getRecentLogs() logs := GetRecentLogs()
renderPage(w, "logs.html", map[string]interface{}{ renderPage(w, "logs.html", map[string]interface{}{
"Title": "Logs", "Title": "Logs",
"Logs": logs, "Logs": logs,
...@@ -559,6 +588,59 @@ func HandleTestMove(w http.ResponseWriter, r *http.Request) { ...@@ -559,6 +588,59 @@ func HandleTestMove(w http.ResponseWriter, r *http.Request) {
}) })
} }
func HandlePortraitUpload(w http.ResponseWriter, r *http.Request) {
botID := chi.URLParam(r, "id")
bot := bots.GetBot(botID)
if bot == nil {
http.Error(w, "Bot not found", http.StatusNotFound)
return
}
r.ParseMultipartForm(10 << 20) // 10MB max
file, header, err := r.FormFile("portrait")
if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest)
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" {
http.Error(w, "Only PNG, JPG, and WebP files are allowed", http.StatusBadRequest)
return
}
os.MkdirAll(PortraitDir, 0755)
filename := botID + ext
destPath := filepath.Join(PortraitDir, filename)
dst, err := os.Create(destPath)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
defer dst.Close()
io.Copy(dst, file)
bot.PortraitURL = "/portraits/" + filename
http.Redirect(w, r, "/admin/bots/edit/"+botID, http.StatusFound)
}
func HandlePortraitServe(w http.ResponseWriter, r *http.Request) {
filename := chi.URLParam(r, "*")
filePath := filepath.Join(PortraitDir, filepath.Base(filename))
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, filePath)
}
// --- Route Registration --- // --- Route Registration ---
// RegisterRoutes mounts all admin routes under the provided router. // RegisterRoutes mounts all admin routes under the provided router.
...@@ -566,6 +648,9 @@ func RegisterRoutes(r chi.Router) { ...@@ -566,6 +648,9 @@ func RegisterRoutes(r chi.Router) {
r.Get("/admin/login", HandleLogin) r.Get("/admin/login", HandleLogin)
r.Post("/admin/login", HandleLogin) r.Post("/admin/login", HandleLogin)
// Serve portraits publicly (no auth needed for API consumers)
r.Get("/portraits/*", HandlePortraitServe)
r.Group(func(protected chi.Router) { r.Group(func(protected chi.Router) {
protected.Use(RequireAuth) protected.Use(RequireAuth)
...@@ -574,6 +659,7 @@ func RegisterRoutes(r chi.Router) { ...@@ -574,6 +659,7 @@ func RegisterRoutes(r chi.Router) {
protected.Get("/admin/bots", HandleBots) protected.Get("/admin/bots", HandleBots)
protected.Get("/admin/bots/edit/{id}", HandleBotEdit) protected.Get("/admin/bots/edit/{id}", HandleBotEdit)
protected.Post("/admin/bots/edit/{id}", HandleBotEdit) protected.Post("/admin/bots/edit/{id}", HandleBotEdit)
protected.Post("/admin/bots/portrait/{id}", HandlePortraitUpload)
protected.Get("/admin/bots/create", HandleBotCreate) protected.Get("/admin/bots/create", HandleBotCreate)
protected.Post("/admin/bots/create", HandleBotCreate) protected.Post("/admin/bots/create", HandleBotCreate)
protected.Post("/admin/bots/delete/{id}", HandleBotDelete) protected.Post("/admin/bots/delete/{id}", HandleBotDelete)
......
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"stockfish-api/internal/admin"
"stockfish-api/internal/bots"
"stockfish-api/internal/engine"
)
const managementAPIKey = "sk-alarc-stockfish-mgmt-2024"
const portraitDir = "/app/portraits"
func ManagementAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" {
key = r.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
}
if key != managementAPIKey {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid or missing API key"})
return
}
next.ServeHTTP(w, r)
})
}
// --- Bot CRUD ---
func HandleAPIListBots(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"bots": bots.ListBots(),
"count": len(bots.Personalities),
})
}
func HandleAPIGetBot(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
bot := bots.GetBot(id)
if bot == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "bot not found"})
return
}
writeJSON(w, http.StatusOK, bot)
}
func HandleAPICreateBot(w http.ResponseWriter, r *http.Request) {
var bot bots.BotPersonality
if err := json.NewDecoder(r.Body).Decode(&bot); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
if bot.ID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id is required"})
return
}
if bot.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
if bot.Style == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "style is required"})
return
}
if bots.GetBot(bot.ID) != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": "bot already exists with id: " + bot.ID})
return
}
if bot.PortraitURL == "" {
bot.PortraitURL = "/portraits/" + bot.ID + ".png"
}
bots.Personalities[bot.ID] = &bot
writeJSON(w, http.StatusCreated, bot)
}
func HandleAPIUpdateBot(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing := bots.GetBot(id)
if existing == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "bot not found"})
return
}
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
if v, ok := updates["name"].(string); ok && v != "" {
existing.Name = v
}
if v, ok := updates["name_ar"].(string); ok {
existing.NameAr = v
}
if v, ok := updates["style"].(string); ok && v != "" {
existing.Style = v
}
if v, ok := updates["style_ar"].(string); ok {
existing.StyleAr = v
}
if v, ok := updates["bio"].(string); ok {
existing.Bio = v
}
if v, ok := updates["bio_ar"].(string); ok {
existing.BioAr = v
}
if v, ok := updates["elo_min"].(float64); ok {
existing.ELOMin = int(v)
}
if v, ok := updates["elo_max"].(float64); ok {
existing.ELOMax = int(v)
}
if v, ok := updates["skill_level"].(float64); ok {
existing.SkillLevel = int(v)
}
if v, ok := updates["depth"].(float64); ok {
existing.Depth = int(v)
}
if v, ok := updates["contempt"].(float64); ok {
existing.Contempt = int(v)
}
if v, ok := updates["blunder_chance"].(float64); ok {
existing.BlunderChance = v
}
if v, ok := updates["think_time_min_ms"].(float64); ok {
existing.ThinkTimeMin = int(v)
}
if v, ok := updates["think_time_max_ms"].(float64); ok {
existing.ThinkTimeMax = int(v)
}
if v, ok := updates["avatar_id"].(string); ok {
existing.AvatarID = v
}
if v, ok := updates["portrait_url"].(string); ok {
existing.PortraitURL = v
}
if v, ok := updates["opening_book"].([]interface{}); ok {
books := make([]string, 0, len(v))
for _, b := range v {
if s, ok := b.(string); ok {
books = append(books, s)
}
}
existing.OpeningBook = books
}
writeJSON(w, http.StatusOK, existing)
}
func HandleAPIReplaceBot(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if bots.GetBot(id) == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "bot not found"})
return
}
var bot bots.BotPersonality
if err := json.NewDecoder(r.Body).Decode(&bot); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
bot.ID = id
if bot.PortraitURL == "" {
bot.PortraitURL = "/portraits/" + id + ".png"
}
bots.Personalities[id] = &bot
writeJSON(w, http.StatusOK, bot)
}
func HandleAPIDeleteBot(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if bots.GetBot(id) == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "bot not found"})
return
}
delete(bots.Personalities, id)
portraitPath := filepath.Join(portraitDir, id+".png")
os.Remove(portraitPath)
portraitPath = filepath.Join(portraitDir, id+".jpg")
os.Remove(portraitPath)
portraitPath = filepath.Join(portraitDir, id+".webp")
os.Remove(portraitPath)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
}
// --- Bot Bulk Operations ---
func HandleAPIBulkCreateBots(w http.ResponseWriter, r *http.Request) {
var botList []bots.BotPersonality
if err := json.NewDecoder(r.Body).Decode(&botList); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
created := []string{}
skipped := []string{}
for i := range botList {
bot := &botList[i]
if bot.ID == "" || bot.Name == "" {
skipped = append(skipped, fmt.Sprintf("index %d: missing id or name", i))
continue
}
if bots.GetBot(bot.ID) != nil {
skipped = append(skipped, bot.ID+" (already exists)")
continue
}
if bot.PortraitURL == "" {
bot.PortraitURL = "/portraits/" + bot.ID + ".png"
}
bots.Personalities[bot.ID] = bot
created = append(created, bot.ID)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"created": created,
"skipped": skipped,
})
}
func HandleAPIBulkDeleteBots(w http.ResponseWriter, r *http.Request) {
var body struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
deleted := []string{}
notFound := []string{}
for _, id := range body.IDs {
if bots.GetBot(id) == nil {
notFound = append(notFound, id)
continue
}
delete(bots.Personalities, id)
deleted = append(deleted, id)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"deleted": deleted,
"not_found": notFound,
})
}
func HandleAPIExportBots(w http.ResponseWriter, r *http.Request) {
allBots := bots.ListBots()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", "attachment; filename=bots_export.json")
json.NewEncoder(w).Encode(allBots)
}
func HandleAPIImportBots(w http.ResponseWriter, r *http.Request) {
overwrite := r.URL.Query().Get("overwrite") == "true"
var botList []bots.BotPersonality
if err := json.NewDecoder(r.Body).Decode(&botList); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
imported := []string{}
skipped := []string{}
for i := range botList {
bot := &botList[i]
if bot.ID == "" {
skipped = append(skipped, fmt.Sprintf("index %d: missing id", i))
continue
}
if bots.GetBot(bot.ID) != nil && !overwrite {
skipped = append(skipped, bot.ID+" (exists, overwrite=false)")
continue
}
if bot.PortraitURL == "" {
bot.PortraitURL = "/portraits/" + bot.ID + ".png"
}
bots.Personalities[bot.ID] = bot
imported = append(imported, bot.ID)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"imported": imported,
"skipped": skipped,
"total": len(bots.Personalities),
})
}
// --- Portrait Management ---
func HandleAPIUploadPortrait(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
bot := bots.GetBot(id)
if bot == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "bot not found"})
return
}
r.ParseMultipartForm(10 << 20)
file, header, err := r.FormFile("portrait")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no file in 'portrait' field"})
return
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only .png, .jpg, .webp allowed"})
return
}
os.MkdirAll(portraitDir, 0755)
filename := id + ext
destPath := filepath.Join(portraitDir, filename)
dst, err := os.Create(destPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to write file"})
return
}
defer dst.Close()
io.Copy(dst, file)
bot.PortraitURL = "/portraits/" + filename
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "uploaded",
"bot_id": id,
"portrait_url": bot.PortraitURL,
"size_bytes": header.Size,
})
}
func HandleAPIDeletePortrait(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
bot := bots.GetBot(id)
if bot == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "bot not found"})
return
}
for _, ext := range []string{".png", ".jpg", ".jpeg", ".webp"} {
os.Remove(filepath.Join(portraitDir, id+ext))
}
bot.PortraitURL = ""
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "bot_id": id})
}
// --- Engine / Pool ---
func HandleAPIPoolStats(w http.ResponseWriter, r *http.Request) {
alive, idle := engine.PoolStats()
s := admin.GetSettings()
writeJSON(w, http.StatusOK, map[string]interface{}{
"pool_alive": alive,
"pool_idle": idle,
"pool_max_size": s.PoolSize,
"pool_utilization": fmt.Sprintf("%.1f%%", float64(alive-idle)/float64(s.PoolSize)*100),
"idle_timeout_sec": s.IdleTimeout,
})
}
func HandleAPITestMove(w http.ResponseWriter, r *http.Request) {
var body struct {
FEN string `json:"fen"`
BotID string `json:"bot_id"`
Depth int `json:"depth"`
SkillLevel int `json:"skill_level"`
Contempt int `json:"contempt"`
TimeLimitMs int `json:"time_limit_ms"`
MultiPV int `json:"multi_pv"`
RawMode bool `json:"raw_mode"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if body.FEN == "" {
body.FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
}
depth := body.Depth
skill := body.SkillLevel
contempt := body.Contempt
multiPV := body.MultiPV
if body.BotID != "" && !body.RawMode {
bot := bots.GetBot(body.BotID)
if bot == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "bot not found"})
return
}
depth = bot.Depth
skill = bot.SkillLevel
contempt = bot.Contempt
}
if depth <= 0 {
depth = 15
}
if multiPV <= 0 {
multiPV = 1
}
req := engine.MoveRequest{
FEN: body.FEN,
Depth: depth,
SkillLevel: skill,
Contempt: contempt,
TimeLimitMs: body.TimeLimitMs,
MultiPV: multiPV,
}
start := time.Now()
resp, err := engine.GetMove(r.Context(), req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "engine error: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"best_move": resp.BestMove,
"evaluation": resp.Evaluation,
"depth": resp.Depth,
"nodes": resp.Nodes,
"pv": resp.PV,
"engine_time_ms": time.Since(start).Milliseconds(),
"request": map[string]interface{}{
"fen": body.FEN,
"depth": depth,
"skill_level": skill,
"contempt": contempt,
"multi_pv": multiPV,
},
})
}
func HandleAPIAnalyzePosition(w http.ResponseWriter, r *http.Request) {
var body struct {
FEN string `json:"fen"`
Depth int `json:"depth"`
Lines int `json:"lines"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if body.FEN == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "fen is required"})
return
}
if body.Depth <= 0 || body.Depth > 40 {
body.Depth = 20
}
if body.Lines <= 0 || body.Lines > 10 {
body.Lines = 5
}
start := time.Now()
lines, err := engine.Analyze(r.Context(), body.FEN, body.Depth, body.Lines)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "engine error: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"fen": body.FEN,
"depth": body.Depth,
"lines": lines,
"engine_time_ms": time.Since(start).Milliseconds(),
})
}
// --- Settings ---
func HandleAPIGetSettings(w http.ResponseWriter, r *http.Request) {
s := admin.GetSettings()
writeJSON(w, http.StatusOK, map[string]interface{}{
"port": s.Port,
"pool_size": s.PoolSize,
"idle_timeout_sec": s.IdleTimeout,
"stockfish_path": os.Getenv("STOCKFISH_PATH"),
})
}
func HandleAPIUpdateSettings(w http.ResponseWriter, r *http.Request) {
var body struct {
PoolSize *int `json:"pool_size"`
IdleTimeout *int `json:"idle_timeout_sec"`
Port *string `json:"port"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
s := admin.GetSettings()
if body.PoolSize != nil {
s.PoolSize = *body.PoolSize
}
if body.IdleTimeout != nil {
s.IdleTimeout = *body.IdleTimeout
}
if body.Port != nil {
s.Port = *body.Port
}
admin.SetSettings(s)
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "updated",
"settings": s,
"note": "some changes require restart to take effect",
})
}
// --- Logs ---
func HandleAPIGetLogs(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 100
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 100 {
limit = v
}
logs := admin.GetRecentLogs()
if len(logs) > limit {
logs = logs[:limit]
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"logs": logs,
"count": len(logs),
})
}
// --- System Info ---
func HandleAPISystemInfo(w http.ResponseWriter, r *http.Request) {
alive, idle := engine.PoolStats()
s := admin.GetSettings()
writeJSON(w, http.StatusOK, map[string]interface{}{
"engine": "stockfish-18",
"version": "1.0.0",
"bot_count": len(bots.Personalities),
"pool": map[string]interface{}{
"alive": alive,
"idle": idle,
"max_size": s.PoolSize,
"idle_timeout": s.IdleTimeout,
},
"settings": map[string]interface{}{
"port": s.Port,
"stockfish_path": os.Getenv("STOCKFISH_PATH"),
},
"endpoints": map[string]string{
"bots_list": "GET /api/manage/bots",
"bot_get": "GET /api/manage/bots/{id}",
"bot_create": "POST /api/manage/bots",
"bot_update": "PATCH /api/manage/bots/{id}",
"bot_replace": "PUT /api/manage/bots/{id}",
"bot_delete": "DELETE /api/manage/bots/{id}",
"bots_bulk_create": "POST /api/manage/bots/bulk",
"bots_bulk_delete": "DELETE /api/manage/bots/bulk",
"bots_export": "GET /api/manage/bots/export",
"bots_import": "POST /api/manage/bots/import?overwrite=true",
"portrait_upload": "POST /api/manage/bots/{id}/portrait",
"portrait_delete": "DELETE /api/manage/bots/{id}/portrait",
"pool_stats": "GET /api/manage/pool",
"test_move": "POST /api/manage/test-move",
"analyze": "POST /api/manage/analyze",
"settings_get": "GET /api/manage/settings",
"settings_update": "PATCH /api/manage/settings",
"logs": "GET /api/manage/logs?limit=50",
"system_info": "GET /api/manage/info",
},
})
}
// --- Register Management Routes ---
func RegisterManagementRoutes(r chi.Router) {
r.Route("/api/manage", func(mgmt chi.Router) {
mgmt.Use(ManagementAuth)
// System
mgmt.Get("/info", HandleAPISystemInfo)
// Bots CRUD
mgmt.Get("/bots", HandleAPIListBots)
mgmt.Post("/bots", HandleAPICreateBot)
mgmt.Get("/bots/export", HandleAPIExportBots)
mgmt.Post("/bots/import", HandleAPIImportBots)
mgmt.Post("/bots/bulk", HandleAPIBulkCreateBots)
mgmt.Delete("/bots/bulk", HandleAPIBulkDeleteBots)
mgmt.Get("/bots/{id}", HandleAPIGetBot)
mgmt.Patch("/bots/{id}", HandleAPIUpdateBot)
mgmt.Put("/bots/{id}", HandleAPIReplaceBot)
mgmt.Delete("/bots/{id}", HandleAPIDeleteBot)
// Portraits
mgmt.Post("/bots/{id}/portrait", HandleAPIUploadPortrait)
mgmt.Delete("/bots/{id}/portrait", HandleAPIDeletePortrait)
// Pool / Engine
mgmt.Get("/pool", HandleAPIPoolStats)
mgmt.Post("/test-move", HandleAPITestMove)
mgmt.Post("/analyze", HandleAPIAnalyzePosition)
// Settings
mgmt.Get("/settings", HandleAPIGetSettings)
mgmt.Patch("/settings", HandleAPIUpdateSettings)
// Logs
mgmt.Get("/logs", HandleAPIGetLogs)
})
}
...@@ -7,6 +7,7 @@ type BotPersonality struct { ...@@ -7,6 +7,7 @@ type BotPersonality struct {
Name string `json:"name"` Name string `json:"name"`
NameAr string `json:"name_ar"` NameAr string `json:"name_ar"`
Style string `json:"style"` Style string `json:"style"`
StyleAr string `json:"style_ar"`
Bio string `json:"bio"` Bio string `json:"bio"`
BioAr string `json:"bio_ar"` BioAr string `json:"bio_ar"`
ELOMin int `json:"elo_min"` ELOMin int `json:"elo_min"`
...@@ -19,6 +20,7 @@ type BotPersonality struct { ...@@ -19,6 +20,7 @@ type BotPersonality struct {
ThinkTimeMax int `json:"think_time_max_ms"` ThinkTimeMax int `json:"think_time_max_ms"`
OpeningBook []string `json:"opening_book"` OpeningBook []string `json:"opening_book"`
AvatarID string `json:"avatar_id"` AvatarID string `json:"avatar_id"`
PortraitURL string `json:"portrait_url"`
} }
var Personalities = map[string]*BotPersonality{ var Personalities = map[string]*BotPersonality{
...@@ -27,6 +29,7 @@ var Personalities = map[string]*BotPersonality{ ...@@ -27,6 +29,7 @@ var Personalities = map[string]*BotPersonality{
Name: "Amina", Name: "Amina",
NameAr: "أمينة المبتدئة", NameAr: "أمينة المبتدئة",
Style: "beginner", Style: "beginner",
StyleAr: "مبتدئة",
Bio: "Just learning chess! Makes mistakes but tries her best.", Bio: "Just learning chess! Makes mistakes but tries her best.",
BioAr: "لسه بتتعلم شطرنج! بتغلط كتير بس بتحاول.", BioAr: "لسه بتتعلم شطرنج! بتغلط كتير بس بتحاول.",
ELOMin: 400, ELOMin: 400,
...@@ -39,12 +42,14 @@ var Personalities = map[string]*BotPersonality{ ...@@ -39,12 +42,14 @@ var Personalities = map[string]*BotPersonality{
ThinkTimeMax: 2000, ThinkTimeMax: 2000,
OpeningBook: []string{}, OpeningBook: []string{},
AvatarID: "bot-amina", AvatarID: "bot-amina",
PortraitURL: "/portraits/amina.png",
}, },
"tarek": { "tarek": {
ID: "tarek", ID: "tarek",
Name: "Tarek", Name: "Tarek",
NameAr: "طارق المتحفظ", NameAr: "طارق المتحفظ",
Style: "defensive", Style: "defensive",
StyleAr: "دفاعي",
Bio: "Plays it safe. Castles early, protects everything, rarely attacks.", Bio: "Plays it safe. Castles early, protects everything, rarely attacks.",
BioAr: "بيلعب أمان. بيتبيّت بدري و بيحمي كل حاجة.", BioAr: "بيلعب أمان. بيتبيّت بدري و بيحمي كل حاجة.",
ELOMin: 800, ELOMin: 800,
...@@ -57,12 +62,14 @@ var Personalities = map[string]*BotPersonality{ ...@@ -57,12 +62,14 @@ var Personalities = map[string]*BotPersonality{
ThinkTimeMax: 3000, ThinkTimeMax: 3000,
OpeningBook: []string{"italian", "queens_gambit_declined", "caro_kann"}, OpeningBook: []string{"italian", "queens_gambit_declined", "caro_kann"},
AvatarID: "bot-tarek", AvatarID: "bot-tarek",
PortraitURL: "/portraits/tarek.png",
}, },
"nour": { "nour": {
ID: "nour", ID: "nour",
Name: "Nour", Name: "Nour",
NameAr: "نور المهاجمة", NameAr: "نور المهاجمة",
Style: "aggressive", Style: "aggressive",
StyleAr: "هجومية",
Bio: "Loves to attack! Sacrifices pieces for a shot at your king.", Bio: "Loves to attack! Sacrifices pieces for a shot at your king.",
BioAr: "بتحب الهجوم! بتضحي بقطع عشان توصل للملك.", BioAr: "بتحب الهجوم! بتضحي بقطع عشان توصل للملك.",
ELOMin: 1000, ELOMin: 1000,
...@@ -75,12 +82,14 @@ var Personalities = map[string]*BotPersonality{ ...@@ -75,12 +82,14 @@ var Personalities = map[string]*BotPersonality{
ThinkTimeMax: 3000, ThinkTimeMax: 3000,
OpeningBook: []string{"kings_gambit", "sicilian_dragon", "evans_gambit"}, OpeningBook: []string{"kings_gambit", "sicilian_dragon", "evans_gambit"},
AvatarID: "bot-nour", AvatarID: "bot-nour",
PortraitURL: "/portraits/nour.png",
}, },
"omar": { "omar": {
ID: "omar", ID: "omar",
Name: "Omar", Name: "Omar",
NameAr: "عمر الاستراتيجي", NameAr: "عمر الاستراتيجي",
Style: "positional", Style: "positional",
StyleAr: "استراتيجي",
Bio: "Controls the center, improves pieces slowly, dominates the endgame.", Bio: "Controls the center, improves pieces slowly, dominates the endgame.",
BioAr: "بيسيطر على المركز، بيحسّن القطع ببطء، و بيكسب النهايات.", BioAr: "بيسيطر على المركز، بيحسّن القطع ببطء، و بيكسب النهايات.",
ELOMin: 1200, ELOMin: 1200,
...@@ -93,12 +102,14 @@ var Personalities = map[string]*BotPersonality{ ...@@ -93,12 +102,14 @@ var Personalities = map[string]*BotPersonality{
ThinkTimeMax: 4000, ThinkTimeMax: 4000,
OpeningBook: []string{"queens_gambit", "english", "reti"}, OpeningBook: []string{"queens_gambit", "english", "reti"},
AvatarID: "bot-omar", AvatarID: "bot-omar",
PortraitURL: "/portraits/omar.png",
}, },
"layla": { "layla": {
ID: "layla", ID: "layla",
Name: "Layla", Name: "Layla",
NameAr: "ليلى المبدعة", NameAr: "ليلى المبدعة",
Style: "creative", Style: "creative",
StyleAr: "إبداعية",
Bio: "Looks for tactics, pins, forks, and sacrifices. Unpredictable.", Bio: "Looks for tactics, pins, forks, and sacrifices. Unpredictable.",
BioAr: "بتدور على التكتيكات والتضحيات. مش هتتوقع خطوتها.", BioAr: "بتدور على التكتيكات والتضحيات. مش هتتوقع خطوتها.",
ELOMin: 1400, ELOMin: 1400,
...@@ -111,12 +122,14 @@ var Personalities = map[string]*BotPersonality{ ...@@ -111,12 +122,14 @@ var Personalities = map[string]*BotPersonality{
ThinkTimeMax: 5000, ThinkTimeMax: 5000,
OpeningBook: []string{"sicilian_najdorf", "kings_indian", "grunfeld"}, OpeningBook: []string{"sicilian_najdorf", "kings_indian", "grunfeld"},
AvatarID: "bot-layla", AvatarID: "bot-layla",
PortraitURL: "/portraits/layla.png",
}, },
"ziad": { "ziad": {
ID: "ziad", ID: "ziad",
Name: "Ziad", Name: "Ziad",
NameAr: "زياد الصلب", NameAr: "زياد الصلب",
Style: "solid", Style: "solid",
StyleAr: "صلب",
Bio: "No weaknesses. Plays principled chess. Hard to beat, rarely blunders.", Bio: "No weaknesses. Plays principled chess. Hard to beat, rarely blunders.",
BioAr: "مفيش نقط ضعف. بيلعب شطرنج محترم. صعب تكسبه.", BioAr: "مفيش نقط ضعف. بيلعب شطرنج محترم. صعب تكسبه.",
ELOMin: 1600, ELOMin: 1600,
...@@ -129,12 +142,14 @@ var Personalities = map[string]*BotPersonality{ ...@@ -129,12 +142,14 @@ var Personalities = map[string]*BotPersonality{
ThinkTimeMax: 6000, ThinkTimeMax: 6000,
OpeningBook: []string{"ruy_lopez", "queens_gambit", "nimzo_indian"}, OpeningBook: []string{"ruy_lopez", "queens_gambit", "nimzo_indian"},
AvatarID: "bot-ziad", AvatarID: "bot-ziad",
PortraitURL: "/portraits/ziad.png",
}, },
"grandmaster": { "grandmaster": {
ID: "grandmaster", ID: "grandmaster",
Name: "Grandmaster Bot", Name: "Grandmaster Bot",
NameAr: "الجراند ماستر", NameAr: "الجراند ماستر",
Style: "near_perfect", Style: "near_perfect",
StyleAr: "شبه مثالي",
Bio: "Full Stockfish strength. Punishes every mistake. Good luck.", Bio: "Full Stockfish strength. Punishes every mistake. Good luck.",
BioAr: "قوة ستوكفيش الكاملة. بيعاقب كل غلطة. حظ سعيد.", BioAr: "قوة ستوكفيش الكاملة. بيعاقب كل غلطة. حظ سعيد.",
ELOMin: 2000, ELOMin: 2000,
...@@ -147,6 +162,7 @@ var Personalities = map[string]*BotPersonality{ ...@@ -147,6 +162,7 @@ var Personalities = map[string]*BotPersonality{
ThinkTimeMax: 8000, ThinkTimeMax: 8000,
OpeningBook: []string{}, OpeningBook: []string{},
AvatarID: "bot-grandmaster", AvatarID: "bot-grandmaster",
PortraitURL: "/portraits/grandmaster.png",
}, },
} }
......
...@@ -3,28 +3,49 @@ ...@@ -3,28 +3,49 @@
<h2>Create New Bot</h2> <h2>Create New Bot</h2>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}} {{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<form method="POST" action="/admin/bots/create"> <form method="POST" action="/admin/bots/create">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group"> <div class="form-group">
<label>ID (lowercase, no spaces)</label> <label>ID (lowercase, no spaces)</label>
<input type="text" name="id" required pattern="[a-z0-9_]+"> <input type="text" name="id" required pattern="[a-z0-9_]+">
</div> </div>
<div class="form-group">
<label>Avatar ID</label>
<input type="text" name="avatar_id" placeholder="bot-name">
</div>
<div class="form-group"> <div class="form-group">
<label>Name</label> <label>Name</label>
<input type="text" name="name" required> <input type="text" name="name" required>
</div> </div>
<div class="form-group">
<label>Name (Arabic)</label>
<input type="text" name="name_ar" dir="rtl">
</div>
<div class="form-group"> <div class="form-group">
<label>Style</label> <label>Style</label>
<input type="text" name="style" required> <input type="text" name="style" required placeholder="e.g. aggressive, defensive, positional">
</div>
<div class="form-group">
<label>Style (Arabic)</label>
<input type="text" name="style_ar" dir="rtl" placeholder="مثلا: هجومي، دفاعي">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label>Bio</label>
<input type="text" name="bio" placeholder="Short description of the bot's personality">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label>Bio (Arabic)</label>
<input type="text" name="bio_ar" dir="rtl" placeholder="وصف قصير لشخصية البوت">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Skill Level (0-20)</label> <label>Skill Level (0-20)</label>
<input type="number" name="skill_level" value="10" min="0" max="20"> <input type="number" name="skill_level" value="10" min="0" max="20">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Depth</label> <label>Depth (1-30)</label>
<input type="number" name="depth" value="10" min="1" max="30"> <input type="number" name="depth" value="10" min="1" max="30">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Contempt</label> <label>Contempt (-100 to 100)</label>
<input type="number" name="contempt" value="0" min="-100" max="100"> <input type="number" name="contempt" value="0" min="-100" max="100">
</div> </div>
<div class="form-group"> <div class="form-group">
...@@ -47,8 +68,12 @@ ...@@ -47,8 +68,12 @@
<label>ELO Max</label> <label>ELO Max</label>
<input type="number" name="elo_max" value="1200" min="0"> <input type="number" name="elo_max" value="1200" min="0">
</div> </div>
</div>
<p style="margin-top: 0.5rem; color: #666; font-size: 0.85rem;">Portrait can be uploaded after creation from the edit page.</p>
<div style="margin-top: 1rem;">
<button type="submit" class="btn btn-success">Create Bot</button> <button type="submit" class="btn btn-success">Create Bot</button>
<a href="/admin/bots" class="btn" style="margin-left:1rem;">Cancel</a> <a href="/admin/bots" class="btn" style="margin-left:1rem;">Cancel</a>
</div>
</form> </form>
</div> </div>
{{end}} {{end}}
...@@ -3,17 +3,62 @@ ...@@ -3,17 +3,62 @@
<h2>Edit Bot: {{.Bot.Name}}</h2> <h2>Edit Bot: {{.Bot.Name}}</h2>
{{if .Success}}<div class="alert alert-success">Bot updated successfully.</div>{{end}} {{if .Success}}<div class="alert alert-success">Bot updated successfully.</div>{{end}}
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}} {{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<!-- Portrait Section -->
<div style="margin-bottom: 1.5rem; padding: 1rem; background: #f8f9fa; border-radius: 8px; display: flex; align-items: center; gap: 1.5rem;">
<div style="width: 128px; height: 128px; border-radius: 8px; overflow: hidden; background: #ddd; flex-shrink: 0;">
{{if .Bot.PortraitURL}}
<img src="{{.Bot.PortraitURL}}" alt="{{.Bot.Name}}" style="width: 100%; height: 100%; object-fit: cover;">
{{else}}
<div style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #999; font-size: 2rem;">&#9820;</div>
{{end}}
</div>
<div>
<p style="margin-bottom: 0.5rem; font-weight: 600;">Portrait (512x512 px)</p>
<form method="POST" action="/admin/bots/portrait/{{.Bot.ID}}" enctype="multipart/form-data" style="display: flex; gap: 0.5rem; align-items: center;">
<input type="file" name="portrait" accept=".png,.jpg,.jpeg,.webp" required style="font-size: 0.85rem;">
<button type="submit" class="btn btn-primary">Upload</button>
</form>
<p style="margin-top: 0.3rem; color: #666; font-size: 0.8rem;">Accepts PNG, JPG, WebP. Will be served at {{.Bot.PortraitURL}}</p>
</div>
</div>
<form method="POST" action="/admin/bots/edit/{{.Bot.ID}}"> <form method="POST" action="/admin/bots/edit/{{.Bot.ID}}">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="{{.Bot.Name}}">
</div>
<div class="form-group">
<label>Name (Arabic)</label>
<input type="text" name="name_ar" value="{{.Bot.NameAr}}" dir="rtl">
</div>
<div class="form-group">
<label>Style</label>
<input type="text" name="style" value="{{.Bot.Style}}">
</div>
<div class="form-group">
<label>Style (Arabic)</label>
<input type="text" name="style_ar" value="{{.Bot.StyleAr}}" dir="rtl">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label>Bio</label>
<input type="text" name="bio" value="{{.Bot.Bio}}">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label>Bio (Arabic)</label>
<input type="text" name="bio_ar" value="{{.Bot.BioAr}}" dir="rtl">
</div>
<div class="form-group"> <div class="form-group">
<label>Skill Level (0-20)</label> <label>Skill Level (0-20)</label>
<input type="number" name="skill_level" value="{{.Bot.SkillLevel}}" min="0" max="20"> <input type="number" name="skill_level" value="{{.Bot.SkillLevel}}" min="0" max="20">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Depth</label> <label>Depth (1-30)</label>
<input type="number" name="depth" value="{{.Bot.Depth}}" min="1" max="30"> <input type="number" name="depth" value="{{.Bot.Depth}}" min="1" max="30">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Contempt</label> <label>Contempt (-100 to 100)</label>
<input type="number" name="contempt" value="{{.Bot.Contempt}}" min="-100" max="100"> <input type="number" name="contempt" value="{{.Bot.Contempt}}" min="-100" max="100">
</div> </div>
<div class="form-group"> <div class="form-group">
...@@ -36,8 +81,11 @@ ...@@ -36,8 +81,11 @@
<label>ELO Max</label> <label>ELO Max</label>
<input type="number" name="elo_max" value="{{.Bot.ELOMax}}" min="0"> <input type="number" name="elo_max" value="{{.Bot.ELOMax}}" min="0">
</div> </div>
</div>
<div style="margin-top: 1rem;">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/admin/bots" class="btn" style="margin-left:1rem;">Cancel</a> <a href="/admin/bots" class="btn" style="margin-left:1rem;">Cancel</a>
</div>
</form> </form>
</div> </div>
{{end}} {{end}}
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th></th>
<th>Name</th> <th>Name</th>
<th>Style</th> <th>Style</th>
<th>ELO</th> <th>ELO</th>
...@@ -18,9 +18,23 @@ ...@@ -18,9 +18,23 @@
<tbody> <tbody>
{{range .Bots}} {{range .Bots}}
<tr> <tr>
<td>{{.ID}}</td> <td>
<td>{{.Name}}</td> <div style="width: 40px; height: 40px; border-radius: 50%; overflow: hidden; background: #eee;">
<td>{{.Style}}</td> {{if .PortraitURL}}
<img src="{{.PortraitURL}}" alt="{{.Name}}" style="width: 100%; height: 100%; object-fit: cover;">
{{else}}
<div style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #999;">&#9820;</div>
{{end}}
</div>
</td>
<td>
<strong>{{.Name}}</strong><br>
<span style="color: #666; font-size: 0.8rem;" dir="rtl">{{.NameAr}}</span>
</td>
<td>
{{.Style}}<br>
<span style="color: #666; font-size: 0.8rem;" dir="rtl">{{.StyleAr}}</span>
</td>
<td>{{.ELOMin}}-{{.ELOMax}}</td> <td>{{.ELOMin}}-{{.ELOMax}}</td>
<td>{{.SkillLevel}}</td> <td>{{.SkillLevel}}</td>
<td>{{.Depth}}</td> <td>{{.Depth}}</td>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment