feat: add four per-verb match action handlers
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user