ai-code-battle/starters/php/index.php
jedarden 01da007045 feat(starter-php): add PHP starter kit (acb-starter-php)
Add starters/php/ with complete starter kit for AI Code Battle:

- index.php: HTTP server with HMAC verification, routing for /turn and /health
- strategy.php: Stub compute_moves() function with example energy-seeking logic
- game.php: Game state types (GameState, Position, VisibleBot, etc.) and grid utilities (toroidal_manhattan, toroidal_chebyshev, bfs, neighbors, cardinal_steps)
- Dockerfile: Alpine-based PHP 8.4 container
- README.md: Quickstart documentation with local/Docker run instructions
- composer.json: Minimal composer config (no external dependencies)

Follows same contract as other starters:
- Listens on port 8080 (BOT_PORT env var)
- POST /turn: Receives game state JSON, returns moves JSON
- GET /health: Health check endpoint
- HMAC-SHA256 signature verification on requests/responses

Reference implementation: bots/guardian/ (GuardianBot in PHP)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 03:35:29 -04:00

178 lines
5 KiB
PHP

<?php
/**
* AI Code Battle - PHP Starter Kit
*
* A minimal bot scaffold with HMAC authentication and a placeholder
* strategy. Implement your strategy in strategy.php.
*
* Usage:
* BOT_SECRET=your-secret php index.php
*/
// Get configuration from environment
$port = getenv('BOT_PORT') ?: '8080';
$secret = getenv('BOT_SECRET');
if (!$secret) {
fwrite(STDERR, "ERROR: BOT_SECRET environment variable is required\n");
exit(1);
}
require_once __DIR__ . '/game.php';
require_once __DIR__ . '/strategy.php';
// Build HTTP server using PHP built-in
$server = stream_socket_server("tcp://0.0.0.0:$port", $errno, $errstr);
if (!$server) {
fwrite(STDERR, "Failed to create server: $errstr ($errno)\n");
exit(1);
}
fwrite(STDOUT, "Bot listening on port $port\n");
while ($conn = stream_socket_accept($server)) {
handle_request($conn, $secret);
fclose($conn);
}
/**
* Handle an incoming HTTP request
*/
function handle_request($conn, string $secret): void {
// Read request
$request = fread($conn, 65536);
// Parse request line
$lines = explode("\r\n", $request);
$requestLine = explode(' ', $lines[0] ?? '');
$method = $requestLine[0] ?? '';
$path = $requestLine[1] ?? '/';
// Parse headers
$headers = [];
$bodyStart = 0;
for ($i = 1; $i < count($lines); $i++) {
if ($lines[$i] === '') {
$bodyStart = $i + 1;
break;
}
$parts = explode(': ', $lines[$i], 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
// Extract body
$body = implode("\r\n", array_slice($lines, $bodyStart));
// Route request
if ($method === 'GET' && $path === '/health') {
send_response($conn, 200, 'text/plain', 'OK');
return;
}
if ($method === 'POST' && $path === '/turn') {
handle_turn($conn, $secret, $headers, $body);
return;
}
send_response($conn, 404, 'text/plain', 'Not Found');
}
/**
* Handle turn request
*/
function handle_turn($conn, string $secret, array $headers, string $body): void {
// Extract auth headers
$matchId = $headers['X-ACB-Match-Id'] ?? '';
$turnStr = $headers['X-ACB-Turn'] ?? '';
$timestamp = $headers['X-ACB-Timestamp'] ?? '';
$signature = $headers['X-ACB-Signature'] ?? '';
if (!$matchId || !$turnStr || !$timestamp || !$signature) {
send_response($conn, 401, 'text/plain', 'Missing auth headers');
return;
}
// Verify signature
if (!verify_signature($secret, $matchId, $turnStr, $timestamp, $body, $signature)) {
send_response($conn, 401, 'text/plain', 'Invalid signature');
return;
}
// Parse game state
$state = json_decode($body, true);
if (!$state) {
send_response($conn, 400, 'text/plain', 'Invalid JSON');
return;
}
$gameState = GameState::fromArray($state);
// Log match start
if ($gameState->turn === 0) {
$seasonId = $gameState->config->seasonId ?? '';
$rulesVersion = $gameState->config->rulesVersion ?? '';
fwrite(STDOUT, "match={$gameState->matchId} season_id={$seasonId} rules_version={$rulesVersion} rows={$gameState->config->rows} cols={$gameState->config->cols}\n");
}
// Compute moves
$moves = compute_moves($gameState);
// Build response
$response = ['moves' => array_map(fn($m) => $m->toArray(), $moves)];
$responseBody = json_encode($response);
// Sign response
$turn = (int)$turnStr;
$responseSig = sign_response($secret, $matchId, $turn, $responseBody);
$headers = [
'Content-Type: application/json',
"X-ACB-Signature: $responseSig"
];
send_response($conn, 200, 'application/json', $responseBody, $headers);
}
/**
* Verify HMAC signature
*/
function verify_signature(string $secret, string $matchId, string $turn, string $timestamp, string $body, string $signature): bool {
$bodyHash = hash('sha256', $body);
$signingString = "$matchId.$turn.$timestamp.$bodyHash";
$expected = hash_hmac('sha256', $signingString, $secret);
return hash_equals($expected, $signature);
}
/**
* Sign response body
*/
function sign_response(string $secret, string $matchId, int $turn, string $body): string {
$bodyHash = hash('sha256', $body);
$signingString = "$matchId.$turn.$bodyHash";
return hash_hmac('sha256', $signingString, $secret);
}
/**
* Send HTTP response
*/
function send_response($conn, int $status, string $contentType, string $body, array $extraHeaders = []): void {
$statusText = [
200 => 'OK',
400 => 'Bad Request',
401 => 'Unauthorized',
404 => 'Not Found',
][$status] ?? 'Unknown';
$response = "HTTP/1.1 $status $statusText\r\n";
$response .= "Content-Type: $contentType\r\n";
$response .= "Content-Length: " . strlen($body) . "\r\n";
foreach ($extraHeaders as $header) {
$response .= "$header\r\n";
}
$response .= "\r\n";
$response .= $body;
fwrite($conn, $response);
}