From df8d4023baaeb2c0aac7644f6b2b47d44aea849e Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 01:05:14 -0500 Subject: [PATCH] feat: add four per-verb match action handlers --- src/Http/Handlers/PostMatchAbility.php | 45 +++ src/Http/Handlers/PostMatchAttack.php | 42 +++ src/Http/Handlers/PostMatchEndTurn.php | 38 +++ src/Http/Handlers/PostMatchMove.php | 76 +++++ tests/Integration/PostMatchActionTest.php | 366 ++++++++++++++++++++++ 5 files changed, 567 insertions(+) create mode 100644 src/Http/Handlers/PostMatchAbility.php create mode 100644 src/Http/Handlers/PostMatchAttack.php create mode 100644 src/Http/Handlers/PostMatchEndTurn.php create mode 100644 src/Http/Handlers/PostMatchMove.php create mode 100644 tests/Integration/PostMatchActionTest.php diff --git a/src/Http/Handlers/PostMatchAbility.php b/src/Http/Handlers/PostMatchAbility.php new file mode 100644 index 0000000..8604a8c --- /dev/null +++ b/src/Http/Handlers/PostMatchAbility.php @@ -0,0 +1,45 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $support = new MatchActionSupport($this->secret); + $result = $support->verify($request); + if ($result->response !== null) { + return $result->response; + } + $matchId = $result->matchIdAndMatch['matchId']; + $match = $result->matchIdAndMatch['match']; + + $payload = json_decode($request->rawBody, true); + $unitId = (string) ($payload['unitId'] ?? ''); + $abilityId = (string) ($payload['abilityId'] ?? ''); + $x = (int) ($payload['x'] ?? -1); + $y = (int) ($payload['y'] ?? -1); + + try { + $next = (new CombatEngine())->useAbility($match, $unitId, $abilityId, new Position($x, $y)); + } catch (CombatException $exception) { + return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage()); + } + + return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next)); + } +} diff --git a/src/Http/Handlers/PostMatchAttack.php b/src/Http/Handlers/PostMatchAttack.php new file mode 100644 index 0000000..34d1773 --- /dev/null +++ b/src/Http/Handlers/PostMatchAttack.php @@ -0,0 +1,42 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $support = new MatchActionSupport($this->secret); + $result = $support->verify($request); + if ($result->response !== null) { + return $result->response; + } + $matchId = $result->matchIdAndMatch['matchId']; + $match = $result->matchIdAndMatch['match']; + + $payload = json_decode($request->rawBody, true); + $attackerId = (string) ($payload['attackerId'] ?? ''); + $targetId = (string) ($payload['targetId'] ?? ''); + + try { + $next = (new CombatEngine())->attack($match, $attackerId, $targetId); + } catch (CombatException $exception) { + return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage()); + } + + return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next)); + } +} diff --git a/src/Http/Handlers/PostMatchEndTurn.php b/src/Http/Handlers/PostMatchEndTurn.php new file mode 100644 index 0000000..6f444d8 --- /dev/null +++ b/src/Http/Handlers/PostMatchEndTurn.php @@ -0,0 +1,38 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $support = new MatchActionSupport($this->secret); + $result = $support->verify($request); + if ($result->response !== null) { + return $result->response; + } + $matchId = $result->matchIdAndMatch['matchId']; + $match = $result->matchIdAndMatch['match']; + + try { + $next = (new CombatEngine())->endTurn($match); + } catch (CombatException $exception) { + return PostMatchMove::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage()); + } + + return Response::json(200, PostMatchMove::successResponse($this->secret, $matchId, $next)); + } +} diff --git a/src/Http/Handlers/PostMatchMove.php b/src/Http/Handlers/PostMatchMove.php new file mode 100644 index 0000000..8f0b30a --- /dev/null +++ b/src/Http/Handlers/PostMatchMove.php @@ -0,0 +1,76 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $support = new MatchActionSupport($this->secret); + $result = $support->verify($request); + if ($result->response !== null) { + return $result->response; + } + $matchId = $result->matchIdAndMatch['matchId']; + $match = $result->matchIdAndMatch['match']; + + $payload = json_decode($request->rawBody, true); + $unitId = (string) ($payload['unitId'] ?? ''); + $x = (int) ($payload['x'] ?? -1); + $y = (int) ($payload['y'] ?? -1); + + try { + $next = (new CombatEngine())->move($match, $unitId, new Position($x, $y)); + } catch (CombatException $exception) { + return self::rejectionResponse($this->secret, $matchId, $match, $exception->getMessage()); + } + + return Response::json(200, self::successResponse($this->secret, $matchId, $next)); + } + + /** @return array */ + public static function successResponse( + string $secret, + string $matchId, + \BattleForge\Domain\MatchState $next + ): array { + $turnToken = TurnToken::issue($secret, $matchId, $next->activeTeamId, $next->round, count($next->actionLog)); + + return [ + 'match' => ScenarioSerializer::matchToArray($next), + 'turnToken' => $turnToken, + 'winnerTeamId' => $next->winnerTeamId, + ]; + } + + public static function rejectionResponse( + string $secret, + string $matchId, + \BattleForge\Domain\MatchState $match, + string $reason + ): Response { + $turnToken = TurnToken::issue($secret, $matchId, $match->activeTeamId, $match->round, count($match->actionLog)); + + return Response::json(409, [ + 'error' => $reason, + 'match' => ScenarioSerializer::matchToArray($match), + 'turnToken' => $turnToken, + ]); + } +} diff --git a/tests/Integration/PostMatchActionTest.php b/tests/Integration/PostMatchActionTest.php new file mode 100644 index 0000000..94b91c1 --- /dev/null +++ b/tests/Integration/PostMatchActionTest.php @@ -0,0 +1,366 @@ +freshMatch(); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchMove(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'unitId' => 'alpha-1', + 'x' => 1, + 'y' => 0, + ], + ), + [], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + $posKey = $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']; + self::assertSame('1:0', $posKey); + self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $body['turnToken']); + self::assertNull($body['winnerTeamId']); + } + + public function testMoveRejectsAnUnreachableDestinationAndDoesNotMutateTheMatch(): void + { + $match = $this->freshMatch(); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchMove(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'unitId' => 'alpha-1', + 'x' => 0, + 'y' => 0, + ], + ), + [], + ); + + self::assertSame(409, $response->status); + $body = json_decode($response->body, true); + self::assertStringContainsString('not reachable', $body['error']); + $posKey = $body['match']['units'][0]['position']['x'] . ':' . $body['match']['units'][0]['position']['y']; + self::assertSame('0:0', $posKey); + } + + public function testAttackHappyPathReducesTargetHealth(): void + { + $match = $this->freshMatch(); + $matchArray = ScenarioSerializer::matchToArray($match); + $matchArray['units'][0]['position'] = ['x' => 6, 'y' => 7]; + $match = ScenarioSerializer::matchFromArray($matchArray); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchAttack(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'attackerId' => 'alpha-1', + 'targetId' => 'bravo-1', + ], + ), + [], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + $bravo = null; + foreach ($body['match']['units'] as $unit) { + if ($unit['id'] === 'bravo-1') { + $bravo = $unit; + break; + } + } + self::assertNotNull($bravo); + self::assertLessThan(10, $bravo['health']); + } + + public function testAttackRejectsAnOutOfRangeTarget(): void + { + $match = $this->freshMatch(); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchAttack(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'attackerId' => 'alpha-1', + 'targetId' => 'bravo-1', + ], + ), + [], + ); + + self::assertSame(409, $response->status); + } + + public function testAbilityHappyPathHealsAnAlly(): void + { + $match = $this->freshMatch(); + $matchArray = ScenarioSerializer::matchToArray($match); + foreach ($matchArray['units'] as &$unit) { + if ($unit['id'] === 'alpha-3') { + $unit['archetype'] = 'support'; + $unit['abilities'] = ['heal']; + } + } + unset($unit); + foreach ($matchArray['units'] as &$unit) { + if ($unit['id'] === 'alpha-2') { + $unit['health'] = 2; + } + } + unset($unit); + $match = ScenarioSerializer::matchFromArray($matchArray); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchAbility(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'unitId' => 'alpha-3', + 'abilityId' => 'heal', + 'x' => 0, + 'y' => 1, + ], + ), + [], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + $healed = null; + foreach ($body['match']['units'] as $unit) { + if ($unit['id'] === 'alpha-2') { + $healed = $unit; + break; + } + } + self::assertGreaterThan(2, $healed['health']); + } + + public function testAbilityRejectsATargetOutsideRange(): void + { + $match = $this->freshMatch(); + $matchArray = ScenarioSerializer::matchToArray($match); + foreach ($matchArray['units'] as &$unit) { + if ($unit['id'] === 'alpha-3') { + $unit['archetype'] = 'support'; + $unit['abilities'] = ['heal']; + } + } + unset($unit); + $match = ScenarioSerializer::matchFromArray($matchArray); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchAbility(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'unitId' => 'alpha-3', + 'abilityId' => 'heal', + 'x' => 7, + 'y' => 7, + ], + ), + [], + ); + + self::assertSame(409, $response->status); + } + + public function testEndTurnHappyPathSwitchesTheActiveTeamAndIncrementsTheRoundAfterBothSides(): void + { + $match = $this->freshMatch(); + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchEndTurn(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + ], + ), + [], + ); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame('bravo', $body['match']['activeTeamId']); + self::assertSame(1, $body['match']['round']); + } + + public function testEndTurnRejectsARequestWithAStaleTurnToken(): void + { + $match = $this->freshMatch(); + $stale = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 99, 0); + $wrongRound = TurnToken::issue(self::SECRET, self::MATCH_ID, 'alpha', 2, 0); + $good = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $staleResponse = (new PostMatchEndTurn(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $stale, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + ], + ), + [], + ); + self::assertSame(409, $staleResponse->status); + $body = json_decode($staleResponse->body, true); + self::assertSame('turn', $body['error'] ?? null); + + $wrongRoundResponse = (new PostMatchEndTurn(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $wrongRound, + cookies: ['__csrf' => $cookie], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + ], + ), + [], + ); + self::assertSame(409, $wrongRoundResponse->status); + } + + private function freshMatch(): MatchState + { + $support = new UnitState( + 'alpha-3', + 'alpha', + new Position(1, 1), + 10, + 10, + 3, + 3, + 2, + 2, + false, + Archetype::Support, + self::HEAL, + ); + + return new MatchState( + new Battlefield(8, 8), + [ + new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 3, 3, 2, 2, false, Archetype::Defender), + new UnitState('alpha-2', 'alpha', new Position(0, 1), 10, 10, 3, 3, 2, 2, false, Archetype::Defender), + $support, + new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker), + new UnitState('bravo-2', 'bravo', new Position(6, 6), 10, 10, 3, 3, 2, 2, false, Archetype::Striker), + new UnitState('bravo-3', 'bravo', new Position(5, 7), 10, 10, 3, 3, 2, 2, false, Archetype::Striker), + ], + 'alpha', + ); + } + + private function tokenFor(MatchState $match): string + { + return TurnToken::issue( + self::SECRET, + self::MATCH_ID, + $match->activeTeamId, + $match->round, + count($match->actionLog), + ); + } + + /** + * @param array $cookies + * @param array $body + */ + private function buildRequest( + MatchState $match, + string $csrf, + string $turnToken, + array $cookies, + array $body + ): Request { + return new Request([], [], [], $cookies, [ + 'HTTP_X_CSRF_TOKEN' => $csrf, + 'HTTP_X_TURN_TOKEN' => $turnToken, + '__csrf_secret' => self::SECRET, + 'CONTENT_TYPE' => 'application/json', + ], '', 'POST', '/matches/current/test', 'application/json', json_encode($body, JSON_THROW_ON_ERROR)); + } +}