367 lines
14 KiB
PHP
367 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace BattleForge\Tests\Integration;
|
|
|
|
use BattleForge\Application\TurnToken;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* End-to-end smoke test. Boots a real `php -S` dev server and walks the
|
|
* Skirmish bundled scenario to a winner. Gated by BATTLEFORGE_E2E=1 so the
|
|
* per-commit CI does not pay its cost.
|
|
*/
|
|
final class PostMatchActionSmokeTest extends TestCase
|
|
{
|
|
private const SECRET = 'e2e-smoke-secret';
|
|
private const MATCH_ID = '0123456789abcdef';
|
|
private const BASE_URL = 'http://127.0.0.1:8765';
|
|
private const CSRF_COOKIE_NAME = '__csrf';
|
|
|
|
/** @var resource|null */
|
|
private $serverProcess = null;
|
|
private string $csrfToken = '';
|
|
private string $csrfCookie = '';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
if (getenv('BATTLEFORGE_E2E') !== '1') {
|
|
self::markTestSkipped('Set BATTLEFORGE_E2E=1 to run the end-to-end smoke test.');
|
|
}
|
|
$this->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<string, mixed> */
|
|
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<string, mixed> $body
|
|
* @return array{status: int, body: array<string, mixed>}
|
|
*/
|
|
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<string, mixed> $match
|
|
* @return array{verb: string, body: array<string, mixed>}
|
|
*/
|
|
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,
|
|
],
|
|
];
|
|
}
|
|
}
|