serverProcess = $this->bootServer(); $this->primeCsrf(); } protected function tearDown(): void { if (is_resource($this->serverProcess)) { proc_terminate($this->serverProcess, 9); proc_close($this->serverProcess); } @unlink(__DIR__ . '/../../var/secret.key'); } public function testHomePageReturnsTheBundledScenariosList(): void { $body = $this->get('/'); self::assertStringContainsString('id="bf-bundled"', $body); self::assertStringContainsString('data-bf-bundle="skirmish"', $body); } public function testSkirmishScenarioStartsAndCanBePlayedToAWinner(): void { $scenario = $this->getJson('/scenarios/bundled/skirmish'); self::assertSame('skirmish', $scenario['id'] ?? null); $startResponse = $this->postJson('/scenarios/skirmish/start', $scenario); self::assertSame(200, $startResponse['status']); $match = $startResponse['body']['match'] ?? null; $matchId = $startResponse['body']['matchId'] ?? null; $turnToken = $startResponse['body']['turnToken'] ?? null; self::assertIsArray($match); self::assertIsString($matchId); self::assertMatchesRegularExpression('/^[a-f0-9]{16,}$/', $matchId); self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $turnToken); for ($i = 0; $i < 200; $i += 1) { if (!empty($match['winnerTeamId'])) { break; } $action = $this->pickAction($match, $matchId); $response = $this->postJson("/matches/current/{$action['verb']}", $action['body']); $errMsg = "Step {$i} verb={$action['verb']} failed: " . ($response['body']['error'] ?? ''); self::assertSame(200, $response['status'], $errMsg); $match = $response['body']['match']; $turnToken = $response['body']['turnToken']; } self::assertNotNull($match['winnerTeamId'] ?? null, 'Skirmish did not produce a winner within 200 actions.'); } public function testStaleTurnTokenReturns409(): void { $scenario = $this->getJson('/scenarios/bundled/skirmish'); $startResponse = $this->postJson('/scenarios/skirmish/start', $scenario); self::assertSame(200, $startResponse['status']); $match = $startResponse['body']['match']; $matchId = $startResponse['body']['matchId']; $staleToken = TurnToken::issue(self::SECRET, $matchId, 'alpha', 99, 0); $response = $this->postJson('/matches/current/end-turn', [ 'matchId' => $matchId, 'match' => $match, ], $staleToken); self::assertSame(409, $response['status']); self::assertSame('turn', $response['body']['error'] ?? null); } public function testBundledHoldTheLineAndLastStandStartMatches(): void { foreach (['hold-the-line', 'last-stand'] as $id) { $scenario = $this->getJson("/scenarios/bundled/{$id}"); $response = $this->postJson("/scenarios/{$id}/start", $scenario); self::assertSame(200, $response['status'], "Failed to start {$id}"); self::assertSame( 'alpha', $response['body']['match']['activeTeamId'] ?? null, "{$id} did not start with alpha", ); self::assertNotEmpty($response['body']['matchId']); self::assertNotEmpty($response['body']['turnToken']); } } /** @return resource */ private function bootServer() { $this->writeSecret(); $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $env = [ 'PATH' => getenv('PATH') ?: '', 'BATTLEFORGE_SECRET' => self::SECRET, ]; $process = proc_open( ['php', '-S', '127.0.0.1:8765', '-t', __DIR__ . '/../../public'], $descriptors, $pipes, __DIR__ . '/../..', $env, ); if (!is_resource($process)) { self::fail('Failed to start php -S'); } // Wait for the server to print "PHP X.Y.Z Development Server (...) started" $start = microtime(true); while (microtime(true) - $start < 5.0) { $line = fgets($pipes[1]); if ($line !== false && str_contains($line, 'started')) { break; } usleep(50_000); } return $process; } private function writeSecret(): void { @unlink(__DIR__ . '/../../var/secret.key'); $dir = __DIR__ . '/../../var'; if (!is_dir($dir)) { mkdir($dir, 0700, true); } // We don't need an actual random secret because BATTLEFORGE_SECRET env wins. // Just touch the file to make sure the dir exists. file_put_contents($dir . '/secret.key', 'unused-because-env-wins'); } private function primeCsrf(): void { $ctx = stream_context_create(['http' => ['ignore_errors' => true, 'header' => '']]); $body = (string) file_get_contents(self::BASE_URL . '/', false, $ctx); $headers = (string) ($http_response_header[0] ?? ''); foreach ($http_response_header as $line) { if (preg_match('/^Set-Cookie:\\s*' . self::CSRF_COOKIE_NAME . '=([^;]+)/i', $line, $m)) { $this->csrfCookie = $m[1]; $this->csrfToken = $this->csrfCookie; return; } } self::fail('Could not prime CSRF cookie: ' . $body); } /** @return array */ private function getJson(string $path): array { $ctx = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n", 'ignore_errors' => true, ], ]); $body = (string) file_get_contents(self::BASE_URL . $path, false, $ctx); $data = json_decode($body, true); if (!is_array($data)) { self::fail("GET {$path} did not return JSON: {$body}"); } return $data; } private function get(string $path): string { $ctx = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie . "\r\n", 'ignore_errors' => true, ], ]); return (string) file_get_contents(self::BASE_URL . $path, false, $ctx); } /** * @param array $body * @return array{status: int, body: array} */ private function postJson(string $path, array $body, ?string $turnToken = null): array { $headers = [ 'Content-Type: application/json', 'X-CSRF-Token: ' . $this->csrfToken, 'Cookie: ' . self::CSRF_COOKIE_NAME . '=' . $this->csrfCookie, ]; if ($turnToken !== null) { $headers[] = 'X-Turn-Token: ' . $turnToken; } elseif (isset($body['match'])) { $match = $body['match']; $matchId = (string) ($body['matchId'] ?? ''); $turnToken = TurnToken::issue( self::SECRET, $matchId, (string) ($match['activeTeamId'] ?? 'alpha'), (int) ($match['round'] ?? 1), count((array) ($match['actionLog'] ?? [])), ); $headers[] = 'X-Turn-Token: ' . $turnToken; } $ctx = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => json_encode($body, JSON_THROW_ON_ERROR), 'ignore_errors' => true, ], ]); $response = (string) file_get_contents(self::BASE_URL . $path, false, $ctx); $status = 0; foreach ($http_response_header as $line) { if (preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) { $status = (int) $m[1]; break; } } $decoded = json_decode($response, true); return ['status' => $status, 'body' => is_array($decoded) ? $decoded : ['raw' => $response]]; } /** * Pick a sensible action for the current match. The strategy: * - if any active unit is adjacent to an enemy, attack. * - otherwise move toward the nearest enemy. * - if no enemy is reachable this turn, end the turn. * * @param array $match * @param string $matchId * @return array{verb: string, body: array} */ private function pickAction(array $match, string $matchId): array { $units = $match['units']; $active = $match['activeTeamId']; $enemy = $active === 'alpha' ? 'bravo' : 'alpha'; foreach ($units as $unit) { if ($unit['teamId'] !== $active || $unit['health'] <= 0) { continue; } foreach ($units as $other) { if ($other['teamId'] !== $enemy || $other['health'] <= 0) { continue; } $dist = abs($unit['position']['x'] - $other['position']['x']) + abs($unit['position']['y'] - $other['position']['y']); if ($dist === 1 && $unit['actionsRemaining'] > 0 && empty($unit['hasAttacked'])) { return [ 'verb' => 'attack', 'body' => [ 'matchId' => $matchId, 'match' => $match, 'attackerId' => $unit['id'], 'targetId' => $other['id'], ], ]; } } } foreach ($units as $unit) { if ($unit['teamId'] !== $active || $unit['health'] <= 0 || $unit['actionsRemaining'] === 0) { continue; } $bestTarget = null; $bestDist = PHP_INT_MAX; foreach ($units as $other) { if ($other['teamId'] !== $enemy || $other['health'] <= 0) { continue; } $dist = abs($unit['position']['x'] - $other['position']['x']) + abs($unit['position']['y'] - $other['position']['y']); if ($dist < $bestDist) { $bestDist = $dist; $bestTarget = $other; } } if ($bestTarget) { $dx = $bestTarget['position']['x'] - $unit['position']['x']; $dy = $bestTarget['position']['y'] - $unit['position']['y']; $stepX = $dx === 0 ? 0 : ($dx > 0 ? 1 : -1); $stepY = $dy === 0 ? 0 : ($dy > 0 ? 1 : -1); $candidates = []; if ($stepX !== 0) { $candidates[] = [ 'x' => $unit['position']['x'] + $stepX, 'y' => $unit['position']['y'], ]; } if ($stepY !== 0) { $candidates[] = [ 'x' => $unit['position']['x'], 'y' => $unit['position']['y'] + $stepY, ]; } $candidates[] = [ 'x' => $unit['position']['x'] + ($stepX ?: 0), 'y' => $unit['position']['y'] + ($stepY ?: 0), ]; foreach ($candidates as $c) { $inBounds = $c['x'] >= 0 && $c['x'] < $match['battlefield']['width'] && $c['y'] >= 0 && $c['y'] < $match['battlefield']['height']; if (!$inBounds) { continue; } $occupied = false; foreach ($units as $other) { if ($other['id'] === $unit['id'] || $other['health'] <= 0) { continue; } if ($other['position']['x'] === $c['x'] && $other['position']['y'] === $c['y']) { $occupied = true; break; } } if ($occupied) { continue; } return [ 'verb' => 'move', 'body' => [ 'matchId' => $matchId, 'match' => $match, 'unitId' => $unit['id'], 'x' => $c['x'], 'y' => $c['y'], ], ]; } } } return [ 'verb' => 'end-turn', 'body' => [ 'matchId' => $matchId, 'match' => $match, ], ]; } }