diff --git a/starters/php/Dockerfile b/starters/php/Dockerfile new file mode 100644 index 0000000..5483257 --- /dev/null +++ b/starters/php/Dockerfile @@ -0,0 +1,12 @@ +FROM php:8.4-cli-alpine + +WORKDIR /app + +COPY index.php strategy.php game.php composer.json ./ + +ENV BOT_PORT=8080 +ENV BOT_SECRET="" + +EXPOSE 8080 + +CMD ["php", "index.php"] diff --git a/starters/php/README.md b/starters/php/README.md new file mode 100644 index 0000000..a778892 --- /dev/null +++ b/starters/php/README.md @@ -0,0 +1,74 @@ +# acb-starter-php + +PHP 8 starter kit for [AI Code Battle](https://aicodebattle.com) — a competitive bot programming platform. + +## Quick Start + +```bash +# Run locally +BOT_SECRET=dev-secret php index.php + +# Run with Docker +docker build -t my-bot . +docker run -e BOT_SECRET=your-secret -p 8080:8080 my-bot +``` + +Your bot listens on port 8080 and responds to `POST /turn` with move commands. + +## Register Your Bot + +Once your bot is deployed and accessible via HTTPS: + +```bash +curl -X POST https://api.aicodebattle.com/api/register \ + -H "Content-Type: application/json" \ + -d '{ + "name": "my-php-bot", + "endpoint_url": "https://my-bot.example.com", + "owner": "your-name", + "description": "My awesome bot" + }' +``` + +Save the `bot_id` and `shared_secret` from the response — the secret is shown only once. + +## Project Structure + +``` +index.php # HTTP server, HMAC auth, and routing +strategy.php # Your bot logic (implement compute_moves()) +game.php # Game state types and grid utilities +composer.json # Composer config (no external deps required) +Dockerfile # Container build +``` + +## Grid Helpers + +`game.php` provides utility functions for the toroidal grid: + +- `toroidal_manhattan($a, $b, $rows, $cols)` — Manhattan distance with wrap-around +- `toroidal_chebyshev($a, $b, $rows, $cols)` — Chebyshev distance with wrap-around +- `neighbors($p, $rows, $cols)` — 8-directional neighbors with wrap +- `cardinal_steps($p, $rows, $cols)` — Cardinal directions with positions +- `bfs($start, $goal, $passable, $rows, $cols)` — BFS pathfinding, returns path or `null` + +## Customization + +Edit `compute_moves()` in `strategy.php` to implement your strategy. The `GameState` object provides: + +- `bots` — all visible bots (yours and enemies) +- `energy` — visible energy pickup locations +- `cores` — visible core positions +- `walls` — visible wall positions +- `you->energy` — your current energy count +- `you->score` — your current score +- `config` — match parameters (grid size, vision radius, etc.) + +Return an array of `Move` objects, each with the bot's current `position` and a `direction` (`"N"`, `"E"`, `"S"`, or `"W"`). Bots not included in the moves array stay in place. + +## Protocol + +- **Endpoint:** `POST /turn` — receives game state JSON, returns moves JSON +- **Health:** `GET /health` — must return 200 +- **Timeout:** 3 seconds per turn +- **Auth:** HMAC-SHA256 via `X-ACB-Signature` header diff --git a/starters/php/composer.json b/starters/php/composer.json new file mode 100644 index 0000000..063c65f --- /dev/null +++ b/starters/php/composer.json @@ -0,0 +1,14 @@ +{ + "name": "acb-starter-php", + "description": "PHP starter kit for AI Code Battle", + "type": "project", + "require": { + "php": ">=8.4" + }, + "license": "MIT", + "autoload": { + "psr-4": { + "AcbStarter\\": "src/" + } + } +} diff --git a/starters/php/game.php b/starters/php/game.php new file mode 100644 index 0000000..8de5afc --- /dev/null +++ b/starters/php/game.php @@ -0,0 +1,288 @@ +row = $row; + $this->col = $col; + } + + public static function fromArray(array $data): self { + return new self($data['row'], $data['col']); + } + + public function toArray(): array { + return ['row' => $this->row, 'col' => $this->col]; + } + + public function equals(Position $other): bool { + return $this->row === $other->row && $this->col === $other->col; + } + + public function __toString(): string { + return "{$this->row},{$this->col}"; + } +} + +/** + * Game configuration + */ +class GameConfig { + public int $rows; + public int $cols; + public int $maxTurns; + public int $visionRadius2; + public int $attackRadius2; + public int $spawnCost; + public int $energyInterval; + public ?string $seasonId; + public ?string $rulesVersion; + + public static function fromArray(array $data): self { + $config = new self(); + $config->rows = $data['rows']; + $config->cols = $data['cols']; + $config->maxTurns = $data['max_turns']; + $config->visionRadius2 = $data['vision_radius2']; + $config->attackRadius2 = $data['attack_radius2']; + $config->spawnCost = $data['spawn_cost']; + $config->energyInterval = $data['energy_interval']; + $config->seasonId = $data['season_id'] ?? null; + $config->rulesVersion = $data['rules_version'] ?? null; + return $config; + } +} + +/** + * Player info + */ +class PlayerInfo { + public int $id; + public int $energy; + public int $score; + + public static function fromArray(array $data): self { + $info = new self(); + $info->id = $data['id']; + $info->energy = $data['energy']; + $info->score = $data['score']; + return $info; + } +} + +/** + * Visible bot + */ +class VisibleBot { + public Position $position; + public int $owner; + + public static function fromArray(array $data): self { + $bot = new self(); + $bot->position = Position::fromArray($data['position']); + $bot->owner = $data['owner']; + return $bot; + } +} + +/** + * Visible core + */ +class VisibleCore { + public Position $position; + public int $owner; + public bool $active; + + public static function fromArray(array $data): self { + $core = new self(); + $core->position = Position::fromArray($data['position']); + $core->owner = $data['owner']; + $core->active = $data['active']; + return $core; + } +} + +/** + * Fog-filtered game state + */ +class GameState { + public string $matchId; + public int $turn; + public GameConfig $config; + public PlayerInfo $you; + /** @var VisibleBot[] */ + public array $bots = []; + /** @var Position[] */ + public array $energy = []; + /** @var VisibleCore[] */ + public array $cores = []; + /** @var Position[] */ + public array $walls = []; + /** @var VisibleBot[] */ + public array $dead = []; + + public static function fromArray(array $data): self { + $state = new self(); + $state->matchId = $data['match_id']; + $state->turn = $data['turn']; + $state->config = GameConfig::fromArray($data['config']); + $state->you = PlayerInfo::fromArray($data['you']); + + foreach ($data['bots'] ?? [] as $bot) { + $state->bots[] = VisibleBot::fromArray($bot); + } + + foreach ($data['energy'] ?? [] as $pos) { + $state->energy[] = Position::fromArray($pos); + } + + foreach ($data['cores'] ?? [] as $core) { + $state->cores[] = VisibleCore::fromArray($core); + } + + foreach ($data['walls'] ?? [] as $pos) { + $state->walls[] = Position::fromArray($pos); + } + + foreach ($data['dead'] ?? [] as $bot) { + $state->dead[] = VisibleBot::fromArray($bot); + } + + return $state; + } +} + +/** + * A single move command + */ +class Move { + public Position $position; + public string $direction; + + public function __construct(Position $position, string $direction) { + $this->position = $position; + $this->direction = $direction; + } + + public function toArray(): array { + return [ + 'position' => $this->position->toArray(), + 'direction' => $this->direction + ]; + } +} + +// ============================================================================ +// Grid Utilities +// ============================================================================ + +/** + * Calculate Manhattan distance with toroidal wrapping + */ +function toroidal_manhattan(Position $a, Position $b, int $rows, int $cols): int { + $dr = abs($a->row - $b->row); + $dc = abs($a->col - $b->col); + $dr = min($dr, $rows - $dr); + $dc = min($dc, $cols - $dc); + return $dr + $dc; +} + +/** + * Calculate Chebyshev distance with toroidal wrapping + */ +function toroidal_chebyshev(Position $a, Position $b, int $rows, int $cols): int { + $dr = abs($a->row - $b->row); + $dc = abs($a->col - $b->col); + $dr = min($dr, $rows - $dr); + $dc = min($dc, $cols - $dc); + return max($dr, $dc); +} + +/** + * Get 8-directional neighbors with wrap-around + * @return Position[] + */ +function neighbors(Position $p, int $rows, int $cols): array { + $offsets = [ + [-1, -1], [-1, 0], [-1, 1], + [0, -1], [0, 1], + [1, -1], [1, 0], [1, 1] + ]; + $result = []; + foreach ($offsets as [$dr, $dc]) { + $result[] = new Position( + ($p->row + $dr + $rows) % $rows, + ($p->col + $dc + $cols) % $cols + ); + } + return $result; +} + +/** + * Get cardinal direction steps with wrap-around + * @return array Array of ['pos' => Position, 'dir' => string] + */ +function cardinal_steps(Position $p, int $rows, int $cols): array { + $steps = [ + [-1, 0, 'N'], + [0, 1, 'E'], + [1, 0, 'S'], + [0, -1, 'W'] + ]; + $result = []; + foreach ($steps as [$dr, $dc, $dir]) { + $result[] = [ + 'pos' => new Position( + ($p->row + $dr + $rows) % $rows, + ($p->col + $dc + $cols) % $cols + ), + 'dir' => $dir + ]; + } + return $result; +} + +/** + * BFS pathfinding on a toroidal grid + * + * @param Position $start Starting position + * @param Position $goal Goal position + * @param callable $passable callable(Position): bool - returns true if cell can be entered + * @param int $rows Grid rows + * @param int $cols Grid cols + * @return Position[]|null Array of positions from start to goal (excluding start), or null if unreachable + */ +function bfs(Position $start, Position $goal, callable $passable, int $rows, int $cols): ?array { + if ($start->equals($goal)) { + return []; + } + + $queue = [[$start, []]]; + $visited = [(string)$start => true]; + + while (!empty($queue)) { + [$current, $path] = array_shift($queue); + + foreach (neighbors($current, $rows, $cols) as $next) { + if ($next->equals($goal)) { + return [...$path, $next]; + } + + $key = (string)$next; + if (!isset($visited[$key]) && $passable($next)) { + $visited[$key] = true; + $queue[] = [$next, [...$path, $next]]; + } + } + } + + return null; +} diff --git a/starters/php/index.php b/starters/php/index.php new file mode 100644 index 0000000..bdc79a2 --- /dev/null +++ b/starters/php/index.php @@ -0,0 +1,178 @@ +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); +} diff --git a/starters/php/strategy.php b/starters/php/strategy.php new file mode 100644 index 0000000..af04cb9 --- /dev/null +++ b/starters/php/strategy.php @@ -0,0 +1,62 @@ +you->id; + $config = $state->config; + $rows = $config->rows; + $cols = $config->cols; + + $moves = []; + + foreach ($state->bots as $bot) { + if ($bot->owner !== $myId) { + continue; + } + + // Example: Find nearest energy and move toward it + if (!empty($state->energy)) { + $bestDist = PHP_INT_MAX; + $bestDir = null; + + foreach (cardinal_steps($bot->position, $rows, $cols) as $step) { + foreach ($state->energy as $energy) { + $dist = toroidal_manhattan($step['pos'], $energy, $rows, $cols); + if ($dist < $bestDist) { + $bestDist = $dist; + $bestDir = $step['dir']; + } + } + } + + if ($bestDir) { + $moves[] = new Move($bot->position, $bestDir); + continue; + } + } + + // Default: hold position (don't add a move) + // Or move randomly: + // $directions = ['N', 'E', 'S', 'W']; + // $moves[] = new Move($bot->position, $directions[array_rand($directions)]); + } + + return $moves; +}