Skip to content

Commit c6f92f8

Browse files
claudemiskoune
authored andcommitted
Add kill-contest endpoint to PlayerController
Implements a new endpoint that allows dying players to contest their death and return to ALIVE status. This feature enables players to challenge their elimination during the game. Changes: - Add ContestKillUseCase to handle the kill contest business logic - Add /player/{id}/kill-contest endpoint in PlayerController - Implement validation to ensure player is DYING and room is IN_GAME - Add comprehensive unit tests for ContestKillUseCase - Add functional tests covering various scenarios: - Successful kill contest - Attempting to contest when not dying - Attempting to contest when already killed - Attempting to contest when room is not in game - Authorization checks - Multiple kill contests in a game
1 parent 3ae07c6 commit c6f92f8

File tree

4 files changed

+457
-0
lines changed

4 files changed

+457
-0
lines changed

src/Api/Controller/PlayerController.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use App\Api\Exception\KillerBadRequestHttpException;
88
use App\Application\UseCase\Player\ChangeRoomUseCase;
9+
use App\Application\UseCase\Player\ContestKillUseCase;
910
use App\Application\UseCase\Player\GuessKillerUseCase;
1011
use App\Application\UseCase\Player\KillRequestOnTargetUseCase;
1112
use App\Application\UseCase\Player\SwitchMissionUseCase;
@@ -65,6 +66,7 @@ public function __construct(
6566
private readonly KillRequestOnTargetUseCase $killRequestOnTargetUseCase,
6667
private readonly SwitchMissionUseCase $switchMissionUseCase,
6768
private readonly GuessKillerUseCase $guessKillerUseCase,
69+
private readonly ContestKillUseCase $contestKillUseCase,
6870
) {
6971
}
7072

@@ -292,4 +294,29 @@ public function guessKiller(Request $request, Player $player): JsonResponse
292294

293295
return $this->json(null, Response::HTTP_OK);
294296
}
297+
298+
#[Route('/{id}/kill-contest', name: 'kill_contest', methods: [Request::METHOD_PATCH])]
299+
#[IsGranted(PlayerVoter::EDIT_PLAYER, subject: 'player', message: 'KILLER_KILL_CONTEST_UNAUTHORIZED')]
300+
public function killContest(Player $player): JsonResponse
301+
{
302+
try {
303+
$this->contestKillUseCase->execute($player);
304+
} catch (KillerExceptionInterface $e) {
305+
throw new KillerBadRequestHttpException($e->getMessage());
306+
}
307+
308+
$this->eventDispatcher->dispatch(new PlayerUpdatedEvent($player));
309+
$room = $player->getRoom();
310+
311+
if ($room !== null) {
312+
$this->hub->publish(
313+
sprintf('room/%s', $room->getId()),
314+
$this->serializer->serialize($room, [AbstractNormalizer::GROUPS => 'publish-mercure']),
315+
);
316+
}
317+
318+
$this->logger->info('Kill contest processed for player {user_id}', ['user_id' => $player->getId()]);
319+
320+
return $this->json($player, Response::HTTP_OK, [], [AbstractNormalizer::GROUPS => 'me']);
321+
}
295322
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Application\UseCase\Player;
6+
7+
use App\Domain\Player\Entity\Player;
8+
use App\Domain\Player\Enum\PlayerStatus;
9+
use App\Domain\Player\Exception\PlayerKilledException;
10+
use App\Domain\Player\PlayerRepository;
11+
use App\Domain\Room\Entity\Room;
12+
use App\Domain\Room\Exception\RoomNotInGameException;
13+
use App\Infrastructure\Persistence\PersistenceAdapterInterface;
14+
use Psr\Log\LoggerAwareInterface;
15+
use Psr\Log\LoggerAwareTrait;
16+
use Psr\Log\NullLogger;
17+
18+
class ContestKillUseCase implements LoggerAwareInterface
19+
{
20+
use LoggerAwareTrait;
21+
22+
public function __construct(
23+
private readonly PersistenceAdapterInterface $persistenceAdapter,
24+
private readonly PlayerRepository $playerRepository,
25+
) {
26+
$this->logger = new NullLogger();
27+
}
28+
29+
public function execute(Player $player): void
30+
{
31+
// Validation: Player must be in DYING status
32+
if ($player->getStatus() !== PlayerStatus::DYING) {
33+
throw new PlayerKilledException('PLAYER_NOT_DYING');
34+
}
35+
36+
// Validation: Room must be in game
37+
$room = $player->getRoom();
38+
39+
if ($room?->getStatus() !== Room::IN_GAME) {
40+
throw new RoomNotInGameException('ROOM_NOT_IN_GAME');
41+
}
42+
43+
// Contest the kill - set player back to ALIVE
44+
$player->setStatus(PlayerStatus::ALIVE);
45+
46+
$this->persistenceAdapter->flush();
47+
48+
$this->logger?->info(
49+
'Player {player} successfully contested their death',
50+
['player' => $player->getId()],
51+
);
52+
}
53+
}

tests/Api/KillContestCest.php

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Tests\Api;
6+
7+
use App\Domain\Player\Entity\Player;
8+
use App\Domain\Player\Enum\PlayerStatus;
9+
use App\Domain\Player\PlayerRepository;
10+
use App\Domain\Room\Entity\Room;
11+
use App\Tests\ApiTester;
12+
13+
class KillContestCest
14+
{
15+
public function testContestKillSuccessfully(ApiTester $I): void
16+
{
17+
$data = $this->setupGameWithThreePlayers($I);
18+
19+
// Get John's ID
20+
$johnId = $data['johnId'];
21+
22+
// Admin tries to kill John - this sets John to DYING
23+
$I->setAdminJwtHeader($I);
24+
/** @var int $adminId */
25+
$adminId = $I->grabFromRepository(Player::class, 'id', ['name' => 'Admin']);
26+
$I->sendPatchAsJson(sprintf('/player/%s/kill-target-request', $adminId));
27+
$I->seeResponseCodeIs(200);
28+
29+
// Verify John is now DYING
30+
$I->setJwtHeader($I, 'John');
31+
$I->sendGetAsJson('/player/me');
32+
$I->seeResponseContainsJson([
33+
'status' => PlayerStatus::DYING->value,
34+
]);
35+
36+
// John contests the kill
37+
$I->sendPatchAsJson(sprintf('/player/%s/kill-contest', $johnId));
38+
$I->seeResponseCodeIs(200);
39+
40+
// Verify John is now ALIVE again
41+
$I->sendGetAsJson('/player/me');
42+
$I->seeResponseContainsJson([
43+
'status' => PlayerStatus::ALIVE->value,
44+
]);
45+
}
46+
47+
public function testContestKillWhenNotDying(ApiTester $I): void
48+
{
49+
$data = $this->setupGameWithThreePlayers($I);
50+
51+
// Try to contest kill when John is ALIVE (not DYING)
52+
$I->setJwtHeader($I, 'John');
53+
$I->sendPatchAsJson(sprintf('/player/%s/kill-contest', $data['johnId']));
54+
$I->seeResponseCodeIs(400);
55+
$I->seeResponseContainsJson(['detail' => 'PLAYER_NOT_DYING']);
56+
}
57+
58+
public function testContestKillWhenAlreadyKilled(ApiTester $I): void
59+
{
60+
$data = $this->setupGameWithThreePlayers($I);
61+
62+
// Set John to KILLED status
63+
$I->setJwtHeader($I, 'John');
64+
$I->sendPatchAsJson(sprintf('/player/%s', $data['johnId']), ['status' => PlayerStatus::KILLED->value]);
65+
$I->seeResponseCodeIs(200);
66+
67+
// Try to contest kill when John is KILLED
68+
$I->sendPatchAsJson(sprintf('/player/%s/kill-contest', $data['johnId']));
69+
$I->seeResponseCodeIs(400);
70+
$I->seeResponseContainsJson(['detail' => 'PLAYER_NOT_DYING']);
71+
}
72+
73+
public function testContestKillWhenRoomNotInGame(ApiTester $I): void
74+
{
75+
// Create admin and room but don't start the game
76+
$I->createAdminAndUpdateHeaders($I);
77+
$I->sendPostAsJson('/room');
78+
$I->sendPostAsJson('/mission', ['content' => 'Mission 1']);
79+
80+
$room = $I->grabEntityFromRepository(Room::class, ['name' => 'Admin\'s room']);
81+
82+
// Join room with player1
83+
$I->createPlayerAndUpdateHeaders($I, 'John');
84+
/** @var int $player1Id */
85+
$player1Id = $I->grabFromRepository(Player::class, 'id', ['name' => 'John']);
86+
$I->sendPatchAsJson(sprintf('/player/%d', $player1Id), ['room' => $room->getId()]);
87+
88+
// Manually set player to DYING (bypassing game logic for test purposes)
89+
$player = $I->grabEntityFromRepository(Player::class, ['id' => $player1Id]);
90+
$player->setStatus(PlayerStatus::DYING);
91+
$I->flushToDatabase();
92+
93+
// Try to contest kill when game is not started
94+
$I->sendPatchAsJson(sprintf('/player/%d/kill-contest', $player1Id));
95+
$I->seeResponseCodeIs(400);
96+
$I->seeResponseContainsJson(['detail' => 'ROOM_NOT_IN_GAME']);
97+
}
98+
99+
public function testContestKillUnauthorized(ApiTester $I): void
100+
{
101+
$data = $this->setupGameWithThreePlayers($I);
102+
103+
// Get John's ID
104+
$johnId = $data['johnId'];
105+
106+
// Admin tries to kill John - this sets John to DYING
107+
$I->setAdminJwtHeader($I);
108+
/** @var int $adminId */
109+
$adminId = $I->grabFromRepository(Player::class, 'id', ['name' => 'Admin']);
110+
$I->sendPatchAsJson(sprintf('/player/%s/kill-target-request', $adminId));
111+
$I->seeResponseCodeIs(200);
112+
113+
// Verify John is now DYING
114+
$I->setJwtHeader($I, 'John');
115+
$I->sendGetAsJson('/player/me');
116+
$I->seeResponseContainsJson([
117+
'status' => PlayerStatus::DYING->value,
118+
]);
119+
120+
// Try to make John contest using Doe's authentication
121+
$I->setJwtHeader($I, 'Doe');
122+
$I->sendPatchAsJson(sprintf('/player/%s/kill-contest', $johnId));
123+
$I->seeResponseCodeIs(403);
124+
}
125+
126+
public function testMultipleKillContestsInGame(ApiTester $I): void
127+
{
128+
// Setup game with 4 players for more complex scenarios
129+
$I->createAdminAndUpdateHeaders($I);
130+
$I->sendPostAsJson('/room');
131+
$I->sendPostAsJson('/mission', ['content' => 'Mission 1']);
132+
133+
$room = $I->grabEntityFromRepository(Room::class, ['name' => 'Admin\'s room']);
134+
135+
// Create 3 more players
136+
$I->createPlayerAndUpdateHeaders($I, 'Player1');
137+
/** @var int $player1Id */
138+
$player1Id = $I->grabFromRepository(Player::class, 'id', ['name' => 'Player1']);
139+
$I->sendPatchAsJson(sprintf('/player/%s', $player1Id), ['room' => $room->getId()]);
140+
$I->sendPostAsJson('/mission', ['content' => 'Mission 2']);
141+
142+
$I->createPlayerAndUpdateHeaders($I, 'Player2');
143+
/** @var int $player2Id */
144+
$player2Id = $I->grabFromRepository(Player::class, 'id', ['name' => 'Player2']);
145+
$I->sendPatchAsJson(sprintf('/player/%s', $player2Id), ['room' => $room->getId()]);
146+
$I->sendPostAsJson('/mission', ['content' => 'Mission 3']);
147+
148+
$I->createPlayerAndUpdateHeaders($I, 'Player3');
149+
/** @var int $player3Id */
150+
$player3Id = $I->grabFromRepository(Player::class, 'id', ['name' => 'Player3']);
151+
$I->sendPatchAsJson(sprintf('/player/%s', $player3Id), ['room' => $room->getId()]);
152+
$I->sendPostAsJson('/mission', ['content' => 'Mission 4']);
153+
154+
// Start the game
155+
$I->setAdminJwtHeader($I);
156+
$I->sendPatchAsJson(sprintf('/room/%s', $room->getId()), ['status' => 'IN_GAME']);
157+
$I->seeResponseCodeIs(200);
158+
159+
// Get Player1's killer using repository
160+
$player1Entity = $I->grabEntityFromRepository(Player::class, ['id' => $player1Id]);
161+
/** @var PlayerRepository $playerRepository */
162+
$playerRepository = $I->grabService(PlayerRepository::class);
163+
$player1Killer = $playerRepository->findKiller($player1Entity);
164+
$player1KillerName = $player1Killer?->getName();
165+
166+
if ($player1KillerName === null) {
167+
return;
168+
}
169+
170+
// Player1's killer tries to kill Player1
171+
$I->setJwtHeader($I, $player1KillerName);
172+
$player1KillerId = $player1Killer->getId();
173+
$I->sendPatchAsJson(sprintf('/player/%s/kill-target-request', $player1KillerId));
174+
$I->seeResponseCodeIs(200);
175+
176+
// Verify Player1 is DYING
177+
$I->setJwtHeader($I, 'Player1');
178+
$I->sendGetAsJson('/player/me');
179+
$I->seeResponseContainsJson(['status' => PlayerStatus::DYING->value]);
180+
181+
// Player1 contests the kill
182+
$I->sendPatchAsJson(sprintf('/player/%s/kill-contest', $player1Id));
183+
$I->seeResponseCodeIs(200);
184+
185+
// Verify Player1 is ALIVE
186+
$I->sendGetAsJson('/player/me');
187+
$I->seeResponseContainsJson([
188+
'status' => PlayerStatus::ALIVE->value,
189+
]);
190+
191+
// Now try the same with another player (Player2)
192+
$player2Entity = $I->grabEntityFromRepository(Player::class, ['id' => $player2Id]);
193+
$player2Killer = $playerRepository->findKiller($player2Entity);
194+
$player2KillerName = $player2Killer?->getName();
195+
196+
if ($player2KillerName === null) {
197+
return;
198+
}
199+
200+
// Player2's killer tries to kill Player2
201+
$I->setJwtHeader($I, $player2KillerName);
202+
$player2KillerId = $player2Killer->getId();
203+
$I->sendPatchAsJson(sprintf('/player/%s/kill-target-request', $player2KillerId));
204+
$I->seeResponseCodeIs(200);
205+
206+
// Verify Player2 is DYING
207+
$I->setJwtHeader($I, 'Player2');
208+
$I->sendGetAsJson('/player/me');
209+
$I->seeResponseContainsJson(['status' => PlayerStatus::DYING->value]);
210+
211+
// Player2 contests the kill
212+
$I->sendPatchAsJson(sprintf('/player/%s/kill-contest', $player2Id));
213+
$I->seeResponseCodeIs(200);
214+
215+
// Verify Player2 is ALIVE
216+
$I->sendGetAsJson('/player/me');
217+
$I->seeResponseContainsJson([
218+
'status' => PlayerStatus::ALIVE->value,
219+
]);
220+
}
221+
222+
private function setupGameWithThreePlayers(ApiTester $I): array
223+
{
224+
// Create admin and room
225+
$I->createAdminAndUpdateHeaders($I);
226+
$I->sendPostAsJson('/room');
227+
$I->sendPostAsJson('/mission', ['content' => 'Mission 1']);
228+
229+
$room = $I->grabEntityFromRepository(Room::class, ['name' => 'Admin\'s room']);
230+
231+
// Join room with player1 (John)
232+
$I->createPlayerAndUpdateHeaders($I, 'John');
233+
/** @var int $player1Id */
234+
$player1Id = $I->grabFromRepository(Player::class, 'id', ['name' => 'John']);
235+
$I->sendPatchAsJson(sprintf('/player/%d', $player1Id), ['room' => $room->getId()]);
236+
$I->sendPostAsJson('/mission', ['content' => 'Mission 2']);
237+
238+
// Join room with player2 (Doe)
239+
$I->createPlayerAndUpdateHeaders($I, 'Doe');
240+
/** @var int $player2Id */
241+
$player2Id = $I->grabFromRepository(Player::class, 'id', ['name' => 'Doe']);
242+
$I->sendPatchAsJson(sprintf('/player/%d', $player2Id), ['room' => $room->getId()]);
243+
$I->sendPostAsJson('/mission', ['content' => 'Mission 3']);
244+
245+
// Start the game with admin
246+
$I->setAdminJwtHeader($I);
247+
$I->sendPatchAsJson(sprintf('/room/%s', $room->getId()), ['status' => 'IN_GAME']);
248+
$I->seeResponseCodeIs(200);
249+
250+
return [
251+
'room' => $room,
252+
'adminId' => $I->grabFromRepository(Player::class, 'id', ['name' => 'Admin']),
253+
'johnId' => $player1Id,
254+
'doeId' => $player2Id,
255+
];
256+
}
257+
}

0 commit comments

Comments
 (0)