From d118920739581efac9b586f0e48a584e7f72d291 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 03:25:57 -0500 Subject: [PATCH] test: add end-to-end smoke test for the battle interface --- .../Integration/PostMatchActionSmokeTest.php | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 tests/Integration/PostMatchActionSmokeTest.php diff --git a/tests/Integration/PostMatchActionSmokeTest.php b/tests/Integration/PostMatchActionSmokeTest.php new file mode 100644 index 0000000..5698a1e --- /dev/null +++ b/tests/Integration/PostMatchActionSmokeTest.php @@ -0,0 +1,366 @@ +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::assertSame(self::MATCH_ID, $matchId); + self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $turnToken); + + for ($i = 0; $i < 200; $i += 1) { + if (!empty($match['winnerTeamId'])) { + break; + } + $action = $this->pickAction($match); + $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']; + $staleToken = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 99, 0); + + $response = $this->postJson('/matches/current/end-turn', [ + 'matchId' => self::MATCH_ID, + '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'] ?? self::MATCH_ID); + $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 + * @return array{verb: string, body: array} + */ + private function pickAction(array $match): 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' => self::MATCH_ID, + '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' => self::MATCH_ID, + 'match' => $match, + 'unitId' => $unit['id'], + 'x' => $c['x'], + 'y' => $c['y'], + ], + ]; + } + } + } + return [ + 'verb' => 'end-turn', + 'body' => [ + 'matchId' => self::MATCH_ID, + 'match' => $match, + ], + ]; + } +}