feat: add four per-verb match action handlers

This commit is contained in:
Keith Solomon
2026-07-26 01:05:14 -05:00
parent 47dc95fa56
commit df8d4023ba
5 changed files with 567 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\Position;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchAbility
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $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));
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchAttack
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $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));
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchEndTurn
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $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));
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace BattleForge\Http\Handlers;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\Position;
use BattleForge\Http\MatchActionSupport;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
final class PostMatchMove
{
public function __construct(private readonly string $secret)
{
}
/** @param array<string, string> $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<string, mixed> */
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,
]);
}
}
+366
View File
@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Integration;
use BattleForge\Application\ScenarioSerializer;
use BattleForge\Application\TurnToken;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\UnitState;
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\PostMatchAbility;
use BattleForge\Http\Handlers\PostMatchAttack;
use BattleForge\Http\Handlers\PostMatchEndTurn;
use BattleForge\Http\Handlers\PostMatchMove;
use BattleForge\Http\Request;
use PHPUnit\Framework\TestCase;
final class PostMatchActionTest extends TestCase
{
private const SECRET = 'match-action-test-secret';
private const MATCH_ID = '0123456789abcdef';
private const HEAL = ['heal'];
public function testMoveHappyPathReturnsTheUpdatedMatchAndAFreshTurnToken(): 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' => 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<string, string> $cookies
* @param array<string, mixed> $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));
}
}