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, '__csrf_token' => $token], 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, '__csrf_token' => $token], 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); // Loop until a hit lands; the to-hit roll can miss. $bravo = null; for ($i = 0; $i < 200; $i += 1) { $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, '__csrf_token' => $token], 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; } } if ($bravo !== null && $bravo['health'] < 10) { 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, '__csrf_token' => $token], 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, '__csrf_token' => $token], 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, '__csrf_token' => $token], 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 testMoveIntoWaterEndsTurn(): void { // Build a match where alpha-1 is on dry ground next to a water tile; // alpha-2 and alpha-3 are parked out of the way so the move path is // clear, and the move target (0,1) becomes a water tile whose cost (3) // matches alpha-1's speed budget. $match = $this->freshMatch(); $matchArray = ScenarioSerializer::matchToArray($match); $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; $matchArray['units'][0]['speed'] = 3; $matchArray['units'][1]['position'] = ['x' => 0, 'y' => 3]; // alpha-2 clear of alpha-1's path $matchArray['units'][2]['position'] = ['x' => 0, 'y' => 2]; // alpha-3 clear of alpha-1's path $matchArray['units'][3]['position'] = ['x' => 7, 'y' => 7]; // bravo-1 stays put $matchArray['units'][4]['position'] = ['x' => 6, 'y' => 7]; // bravo-2 $matchArray['units'][5]['position'] = ['x' => 5, 'y' => 7]; // bravo-3 $matchArray['battlefield']['terrain'] = ['0:1' => 'water']; $match = ScenarioSerializer::matchFromArray($matchArray); $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, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), 'unitId' => 'alpha-1', 'x' => 0, 'y' => 1, ], ), [], ); self::assertSame(200, $response->status); $body = json_decode($response->body, true); $moved = null; foreach ($body['match']['units'] as $u) { if ($u['id'] === 'alpha-1') { $moved = $u; } } self::assertNotNull($moved); self::assertSame(['x' => 0, 'y' => 1], $moved['position']); self::assertSame(0, $moved['actionsRemaining'], 'wading ends the turn'); self::assertTrue($moved['wadedThisTurn'], 'wade flag is set after water entry'); } 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, '__csrf_token' => $token], 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, '__csrf_token' => $token], 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, '__csrf_token' => $token], 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)); } }