diff --git a/.env b/.env index 0fbee48..de7a195 100644 --- a/.env +++ b/.env @@ -59,5 +59,8 @@ EXPO_DSN=expo://TOKEN@default ###< symfony/expo-notifier ### ###> sentry/sentry-symfony ### -SENTRY_DSN=https://0424d3887e2cd6660d8b029cf1d31b28@o4506621494755328.ingest.us.sentry.io/4510172011626496 +SENTRY_DSN=sentry-dsn ###< sentry/sentry-symfony ### + +OPENROUTER_API_KEY=open-router-api-key +OPENROUTER_MODEL=openai/gpt-oss-20b:free diff --git a/.gitignore b/.gitignore index d707c74..010f368 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ phpstan.neon ###< phpstan/phpstan ### /deploy-instruction.md +/.claude/ diff --git a/composer.json b/composer.json index 66fa959..b7279e4 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpdoc-parser": "^2.3", "sentry/sentry-symfony": "^5.6", + "symfony/ai-agent": "dev-main", "symfony/console": "^7.3", "symfony/dotenv": "^7.3", "symfony/expo-notifier": "7.3.*", diff --git a/composer.lock b/composer.lock index ced9c0a..6fc925b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "429882da9b1153c8a5f4a77d063729f5", + "content-hash": "ab0d9e806c32f14aa4d1f859fd30949c", "packages": [ { "name": "doctrine/annotations", @@ -2529,6 +2529,57 @@ }, "time": "2021-05-22T15:57:08+00:00" }, + { + "name": "oskarstark/enum-helper", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/OskarStark/enum-helper.git", + "reference": "8279e5289658b407ef247f0bd08a3d7b2611723c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/OskarStark/enum-helper/zipball/8279e5289658b407ef247f0bd08a3d7b2611723c", + "reference": "8279e5289658b407ef247f0bd08a3d7b2611723c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "phpunit/phpunit": "<10" + }, + "require-dev": { + "ergebnis/php-cs-fixer-config": "^5.16", + "phpstan/phpstan": "^1.11.8", + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "OskarStark\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "description": "This library provides helpers for several enum operations", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/OskarStark/enum-helper/issues", + "source": "https://github.com/OskarStark/enum-helper/tree/1.8.0" + }, + "time": "2025-10-14T08:12:56+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -3479,6 +3530,227 @@ ], "time": "2025-09-24T13:41:01+00:00" }, + { + "name": "symfony/ai-agent", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/symfony/ai-agent.git", + "reference": "44b21820e979004f3be6101ae3375774b02b9f85" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ai-agent/zipball/44b21820e979004f3be6101ae3375774b02b9f85", + "reference": "44b21820e979004f3be6101ae3375774b02b9f85", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.4", + "phpstan/phpdoc-parser": "^2.1", + "psr/log": "^3.0", + "symfony/ai-platform": "@dev", + "symfony/clock": "^7.3|^8.0", + "symfony/http-client": "^7.3|^8.0", + "symfony/property-access": "^7.3|^8.0", + "symfony/property-info": "^7.3|^8.0", + "symfony/serializer": "^7.3|^8.0", + "symfony/type-info": "^7.3|^8.0" + }, + "require-dev": { + "mrmysql/youtube-transcript": "^0.0.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.13", + "symfony/ai-store": "@dev", + "symfony/cache": "^7.3|^8.0", + "symfony/css-selector": "^7.3|^8.0", + "symfony/dom-crawler": "^7.3|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.3|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/translation-contracts": "^3.6" + }, + "default-branch": true, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Agent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "description": "PHP library for building agentic applications.", + "keywords": [ + "Agent", + "ai", + "llm" + ], + "support": { + "source": "https://github.com/symfony/ai-agent/tree/main" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-25T10:26:13+00:00" + }, + { + "name": "symfony/ai-platform", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/symfony/ai-platform.git", + "reference": "451f5bef2b877fe936e72a664c7b8fe243ef42b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ai-platform/zipball/451f5bef2b877fe936e72a664c7b8fe243ef42b4", + "reference": "451f5bef2b877fe936e72a664c7b8fe243ef42b4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "oskarstark/enum-helper": "^1.5", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.4", + "phpstan/phpdoc-parser": "^2.1", + "psr/log": "^3.0", + "symfony/clock": "^7.3|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-client": "^7.3|^8.0", + "symfony/property-access": "^7.3|^8.0", + "symfony/property-info": "^7.3|^8.0", + "symfony/serializer": "^7.3|^8.0", + "symfony/type-info": "^7.3|^8.0", + "symfony/uid": "^7.3|^8.0" + }, + "require-dev": { + "async-aws/bedrock-runtime": "^0.1.0", + "codewithkyrian/transformers": "^0.6.2", + "google/auth": "^1.47", + "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0.6", + "phpunit/phpunit": "^11.5", + "symfony/ai-agent": "@dev", + "symfony/console": "^7.3|^8.0", + "symfony/dotenv": "^7.3|^8.0", + "symfony/finder": "^7.3|^8.0", + "symfony/process": "^7.3|^8.0", + "symfony/var-dumper": "^7.3|^8.0" + }, + "default-branch": true, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Platform\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "description": "PHP library for interacting with AI platform provider.", + "keywords": [ + "Gemini", + "OpenRouter", + "ai", + "aimlapi", + "albert", + "anthropic", + "azure", + "bedrock", + "cerebras", + "dockermodelrunner", + "elevenlabs", + "huggingface", + "inference", + "llama", + "lmstudio", + "meta", + "mistral", + "nova", + "ollama", + "openai", + "perplexity", + "replicate", + "transformers", + "vertexai", + "voyage" + ], + "support": { + "source": "https://github.com/symfony/ai-platform/tree/main" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-25T10:26:20+00:00" + }, { "name": "symfony/cache", "version": "v7.3.4", @@ -14288,6 +14560,7 @@ "minimum-stability": "dev", "stability-flags": { "roave/security-advisories": 20, + "symfony/ai-agent": 20, "symfony/mcp-bundle": 20 }, "prefer-stable": true, diff --git a/src/Api/Controller/HealthController.php b/src/Api/Controller/HealthController.php index 180d325..f11dcd5 100644 --- a/src/Api/Controller/HealthController.php +++ b/src/Api/Controller/HealthController.php @@ -17,10 +17,6 @@ class HealthController extends AbstractController implements LoggerAwareInterfac { use LoggerAwareTrait; - public function __construct() - { - } - #[Route('/', name: 'health', methods: [Request::METHOD_GET])] public function health(): JsonResponse { diff --git a/src/Api/Controller/MissionController.php b/src/Api/Controller/MissionController.php index e115c40..bdd3bf6 100644 --- a/src/Api/Controller/MissionController.php +++ b/src/Api/Controller/MissionController.php @@ -4,7 +4,7 @@ namespace App\Api\Controller; -use App\Api\Exception\KillerBadRequestHttpException; +use App\Application\UseCase\Mission\CreateMissionUseCase; use App\Domain\KillerSerializerInterface; use App\Domain\KillerValidatorInterface; use App\Domain\Mission\Entity\Mission; @@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -32,26 +33,30 @@ public function __construct( private readonly SseInterface $hub, private readonly KillerSerializerInterface $serializer, private readonly KillerValidatorInterface $validator, + private readonly CreateMissionUseCase $createMissionUseCase, ) { } #[Route(name: 'create_mission', methods: [Request::METHOD_POST])] #[IsGranted(MissionVoter::CREATE_MISSION, message: 'KILLER_CREATE_MISSION_UNAUTHORIZED')] public function createMission( - #[MapRequestPayload(serializationContext: [AbstractNormalizer::GROUPS => 'post-mission'])] Mission $mission, + #[MapRequestPayload(serializationContext: [AbstractNormalizer::GROUPS => 'post-mission'])] Mission $missionDto, ): JsonResponse { /** @var Player $player */ $player = $this->getUser(); - $room = $player->getRoom(); - if (!$room || $room->getStatus() !== Room::PENDING) { - throw new KillerBadRequestHttpException('CAN_NOT_ADD_MISSIONS'); + $content = $missionDto->getContent(); + if ($content === null) { + throw new BadRequestHttpException('Mission content is required'); } - $player->addAuthoredMission($mission); + $mission = $this->createMissionUseCase->execute($content, $player); - $this->missionRepository->store($mission); - $this->persistenceAdapter->flush(); + $room = $player->getRoom(); + + if ($room === null) { + throw new BadRequestHttpException('Player must be in a room'); + } $this->hub->publish( sprintf('room/%s', $room), diff --git a/src/Api/Controller/RoomController.php b/src/Api/Controller/RoomController.php index 0ba89cc..3bfe58c 100644 --- a/src/Api/Controller/RoomController.php +++ b/src/Api/Controller/RoomController.php @@ -4,12 +4,13 @@ namespace App\Api\Controller; +use App\Api\Dto\GenerateRoomWithMissionsDto; use App\Api\Exception\KillerBadRequestHttpException; -use App\Application\UseCase\Player\ChangeRoomUseCase; +use App\Application\UseCase\Room\CreateRoomUseCase; +use App\Application\UseCase\Room\GenerateRoomWithMissionUseCase; use App\Domain\KillerSerializerInterface; use App\Domain\KillerValidatorInterface; use App\Domain\Player\Entity\Player; -use App\Domain\Player\Enum\PlayerStatus; use App\Domain\Room\Entity\Room; use App\Domain\Room\RoomRepository; use App\Domain\Room\RoomWorkflowTransitionInterface; @@ -20,6 +21,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -29,7 +31,7 @@ #[Route('/room', format: 'json')] class RoomController extends AbstractController { - public const IS_GAME_MASTERED_ROOM = 'isGameMastered'; + public const string IS_GAME_MASTERED_ROOM = 'isGameMastered'; public function __construct( private readonly RoomRepository $roomRepository, @@ -38,7 +40,8 @@ public function __construct( private readonly SseInterface $hub, private readonly KillerSerializerInterface $serializer, private readonly KillerValidatorInterface $validator, - private readonly ChangeRoomUseCase $changeRoomUseCase, + private readonly CreateRoomUseCase $createRoomUseCase, + private readonly GenerateRoomWithMissionUseCase $generateRoomWithMissionUseCase, ) { } @@ -48,23 +51,30 @@ public function createRoom(Request $request): JsonResponse { /** @var Player $player */ $player = $this->getUser(); - $room = (new Room())->setName(sprintf("%s's room", $player->getName())); - - $this->changeRoomUseCase->execute($player, $room); - $player->setRoles(['ROLE_ADMIN']); + $roomName = sprintf("%s's room", $player->getName()); + $isGameMastered = false; if ($request->getContent() !== '') { $data = $request->toArray(); - - if (isset($data[self::IS_GAME_MASTERED_ROOM]) && $data[self::IS_GAME_MASTERED_ROOM]) { - $room->setIsGameMastered(true); - $player->setRoles(['ROLE_MASTER']); - $player->setStatus(PlayerStatus::SPECTATING); - } + $isGameMastered = $data[self::IS_GAME_MASTERED_ROOM] ?? false; } - $this->roomRepository->store($room); - $this->persistenceAdapter->flush(); + $room = $this->createRoomUseCase->execute($player, $roomName, $isGameMastered); + + return $this->json($room, Response::HTTP_CREATED, [], [AbstractNormalizer::GROUPS => 'get-room']); + } + + #[Route('/generate-with-missions', name: 'generate_room_with_missions', methods: [Request::METHOD_POST])] + #[IsGranted(RoomVoter::CREATE_ROOM, message: 'KILLER_CREATE_ROOM_UNAUTHORIZED')] + public function generateRoomWithMissions( + #[MapRequestPayload] GenerateRoomWithMissionsDto $dto, + ): JsonResponse { + /** @var Player $player */ + $player = $this->getUser(); + $roomName = $dto->roomName ?? sprintf("%s's room", $player->getName()); + $missionsCount = $dto->missionsCount ?? 10; + + $room = $this->generateRoomWithMissionUseCase->execute($roomName, $player, $missionsCount, $dto->theme); return $this->json($room, Response::HTTP_CREATED, [], [AbstractNormalizer::GROUPS => 'get-room']); } diff --git a/src/Api/Dto/GenerateRoomWithMissionsDto.php b/src/Api/Dto/GenerateRoomWithMissionsDto.php new file mode 100644 index 0000000..1757383 --- /dev/null +++ b/src/Api/Dto/GenerateRoomWithMissionsDto.php @@ -0,0 +1,17 @@ +getRoom(); + + if (!$room || $room->getStatus() !== Room::PENDING) { + throw new KillerBadRequestHttpException('CAN_NOT_ADD_MISSIONS'); + } + + $mission = new Mission(); + $mission->setContent($content); + $author->addAuthoredMission($mission); + + $this->missionRepository->store($mission); + $this->persistenceAdapter->flush(); + + return $mission; + } +} diff --git a/src/Application/UseCase/Player/ChangeRoomUseCase.php b/src/Application/UseCase/Player/ChangeRoomUseCase.php index 33b381f..2bc4e9f 100644 --- a/src/Application/UseCase/Player/ChangeRoomUseCase.php +++ b/src/Application/UseCase/Player/ChangeRoomUseCase.php @@ -10,9 +10,9 @@ use App\Domain\Room\Entity\Room; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -final readonly class ChangeRoomUseCase +class ChangeRoomUseCase { - public function __construct(private EventDispatcherInterface $eventDispatcher) + public function __construct(private readonly EventDispatcherInterface $eventDispatcher) { } diff --git a/src/Application/UseCase/Room/CreateRoomUseCase.php b/src/Application/UseCase/Room/CreateRoomUseCase.php new file mode 100644 index 0000000..ba215c8 --- /dev/null +++ b/src/Application/UseCase/Room/CreateRoomUseCase.php @@ -0,0 +1,42 @@ +setName($roomName); + + $this->changeRoomUseCase->execute($player, $room); + $player->setRoles(['ROLE_ADMIN']); + + if ($isGameMastered) { + $room->setIsGameMastered(true); + $player->setRoles(['ROLE_MASTER']); + $player->setStatus(PlayerStatus::SPECTATING); + } + + $this->roomRepository->store($room); + $this->persistenceAdapter->flush(); + + return $room; + } +} diff --git a/src/Application/UseCase/Room/GenerateRoomWithMissionUseCase.php b/src/Application/UseCase/Room/GenerateRoomWithMissionUseCase.php new file mode 100644 index 0000000..d5a64fe --- /dev/null +++ b/src/Application/UseCase/Room/GenerateRoomWithMissionUseCase.php @@ -0,0 +1,109 @@ +logger = new NullLogger(); + } + + public function execute( + string $roomName, + Player $gameMaster, + int $missionsCount = self::DEFAULT_MISSIONS_COUNT, + ?MissionTheme $theme = null, + ): Room { + $this->logger?->info('Creating game-mastered room with AI missions', [ + 'room_name' => $roomName, + 'game_master_id' => $gameMaster->getId(), + 'missions_count' => $missionsCount, + 'theme' => $theme, + ]); + + $room = null; + + try { + // Step 1: Create the room with game master mode enabled + $room = $this->createRoomUseCase->execute($gameMaster, $roomName, true); + // Step 2: Generate missions using AI + $missionContents = $this->missionGenerator->generateMissions($missionsCount, $theme); + + // Step 3: Create and associate missions with the room + $this->createMissions($missionContents, $room, $gameMaster); + + // Step 4: Persist everything + $this->persistenceAdapter->flush(); + + $this->logger?->info('Game-mastered room created successfully', [ + 'room_id' => $room->getId(), + 'missions_count' => \count($missionContents), + ]); + + return $room; + } catch (\Throwable $e) { + $this->logger?->error('Failed to create game-mastered room with AI missions', [ + 'room_id' => $room ? $room->getId() : 'N/A', + 'error' => $e->getMessage(), + ]); + + $gameMaster->setRoom(null); + $this->persistenceAdapter->flush(); + + throw new \RuntimeException( + sprintf('Failed to create game-mastered room: %s', $e->getMessage()), + previous: $e, + ); + } + } + + /** + * @param array $missionContents + */ + private function createMissions(array $missionContents, Room $room, Player $gameMaster): void + { + foreach ($missionContents as $index => $content) { + $this->createMissionUseCase->execute($content, $gameMaster); + + $this->logger?->debug('Mission created', [ + 'mission_index' => $index + 1, + 'room_id' => $room->getId(), + ]); + } + + $this->logger?->info('All missions created and associated with room', [ + 'room_id' => $room->getId(), + 'missions_count' => \count($missionContents), + ]); + } +} diff --git a/src/Domain/Mission/Enum/MissionTheme.php b/src/Domain/Mission/Enum/MissionTheme.php new file mode 100644 index 0000000..27c2cc3 --- /dev/null +++ b/src/Domain/Mission/Enum/MissionTheme.php @@ -0,0 +1,18 @@ + Array of mission contents + */ + public function generateMissions(int $count, ?MissionTheme $theme = null): array; +} diff --git a/src/Infrastructure/Ai/Agent/KillerMissionsAgent.php b/src/Infrastructure/Ai/Agent/KillerMissionsAgent.php new file mode 100644 index 0000000..f9b294d --- /dev/null +++ b/src/Infrastructure/Ai/Agent/KillerMissionsAgent.php @@ -0,0 +1,164 @@ +logger->info('Generating missions with OpenRouter AI', [ + 'count' => $count, + 'theme' => $theme?->value, + ]); + + $prompt = $this->buildMissionGenerationPrompt($count, $theme); + + try { + // Create AI Platform instance + $platform = PlatformFactory::create(apiKey: $this->openRouterApiKey); + + // Ensure model is non-empty + if ($this->openRouterModel === '') { + throw new \RuntimeException('OpenRouter model cannot be empty'); + } + + // Create Agent + $agent = new Agent($platform, $this->openRouterModel); + + // Create message bag with system and user messages + $messages = new MessageBag( + Message::forSystem(self::SYSTEM_PROMPT), + Message::ofUser($prompt), + ); + + // Call the agent + $result = $agent->call($messages); + + $content = $result->getContent(); + + if (!$content || !\is_string($content)) { + throw new \RuntimeException('No valid content in OpenRouter API response'); + } + + $this->logger->debug('OpenRouter API response received', [ + 'model' => $this->openRouterModel, + ]); + + // Parse the missions from the response + $missions = $this->parseMissionsFromResponse($content); + + if (\count($missions) < $count) { + $this->logger->warning('Received fewer missions than requested', [ + 'requested' => $count, + 'received' => \count($missions), + ]); + } + + $this->logger->info('Missions generated successfully', [ + 'count' => \count($missions), + ]); + + return \array_slice($missions, 0, $count); + } catch (\Throwable $e) { + $this->logger->error('OpenRouter API call failed', [ + 'error' => $e->getMessage(), + ]); + + throw new \RuntimeException( + 'Failed to generate missions with AI: ' . $e->getMessage(), + previous: $e, + ); + } + } + + private function buildMissionGenerationPrompt(int $count, ?MissionTheme $theme): string + { + $basePrompt = <<value); +// } + + $basePrompt .= << Array of mission contents + */ + private function parseMissionsFromResponse(string $response): array + { + // Split by newlines and filter out empty lines + $lines = array_filter( + array_map('trim', explode("\n", $response)), + static fn (string $line) => $line !== '', + ); + + $missions = []; + + foreach ($lines as $line) { + // Remove numbering patterns like "1.", "1)", "1 -", etc. + $cleanedLine = preg_replace('/^\d+[\.\)\-\:]\s*/', '', $line); + if ($cleanedLine === null) { + continue; + } + + // Remove quotes if the entire mission is quoted + $cleanedLine = trim($cleanedLine, '"\''); + + if ($cleanedLine === '' || \strlen($cleanedLine) < 5) { + continue; + } + + $missions[] = $cleanedLine; + } + + return $missions; + } +} diff --git a/tests/Api/RoomControllerCest.php b/tests/Api/RoomControllerCest.php index fd1bce9..8645b58 100644 --- a/tests/Api/RoomControllerCest.php +++ b/tests/Api/RoomControllerCest.php @@ -725,4 +725,46 @@ public function testStartGameSuccessfullyWithGameMaster(ApiTester $I): void ], ); } + + public function testGenerateRoomWithMissions(ApiTester $I): void + { + $I->createPlayerAndUpdateHeaders($I, self::PLAYER_NAME); + + $I->sendPostAsJson('/room/generate-with-missions', [ + 'roomName' => 'AI Generated Room', + 'missionsCount' => 5, + 'theme' => 'spy', + ]); + + $I->seeResponseCodeIs(201); + + $playerId = $I->grabFromRepository(Player::class, 'id', ['name' => self::PLAYER_NAME]); + + $I->seeResponseContainsJson([ + 'name' => 'AI Generated Room', + 'admin' => ['id' => $playerId], + 'isGameMastered' => true, + ]); + + // Verify room was created + $I->seeInRepository(Room::class, [ + 'name' => 'AI Generated Room', + 'isGameMastered' => true, + ]); + } + + public function testGenerateRoomWithMissionsWithDefaults(ApiTester $I): void + { + $I->createPlayerAndUpdateHeaders($I, self::PLAYER_NAME); + + // Send empty body to test defaults + $I->sendPostAsJson('/room/generate-with-missions', []); + + $I->seeResponseCodeIs(201); + + // Should use default room name + $I->seeResponseContainsJson([ + 'name' => sprintf("%s's room", self::PLAYER_NAME), + ]); + } } diff --git a/tests/Unit/Application/UseCase/Mission/CreateMissionUseCaseTest.php b/tests/Unit/Application/UseCase/Mission/CreateMissionUseCaseTest.php new file mode 100644 index 0000000..39c8e02 --- /dev/null +++ b/tests/Unit/Application/UseCase/Mission/CreateMissionUseCaseTest.php @@ -0,0 +1,182 @@ +missionRepository = $this->prophesize(MissionRepository::class); + $this->persistenceAdapter = $this->prophesize(PersistenceAdapterInterface::class); + + $this->createMissionUseCase = new CreateMissionUseCase( + $this->missionRepository->reveal(), + $this->persistenceAdapter->reveal(), + ); + + parent::setUp(); + } + + public function testExecuteCreatesMissionSuccessfully(): void + { + $room = $this->make(Room::class, [ + 'getStatus' => Expected::atLeastOnce(Room::PENDING), + ]); + + $author = null; + $author = $this->make(Player::class, [ + 'getRoom' => Expected::atLeastOnce($room), + 'addAuthoredMission' => Expected::once(static function ($mission) use (&$author) { + return $author; + }), + ]); + + $missionContent = 'Test mission content'; + + $this->missionRepository->store(Argument::type(Mission::class))->shouldBeCalledOnce(); + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + $mission = $this->createMissionUseCase->execute($missionContent, $author); + + $this->assertInstanceOf(Mission::class, $mission); + $this->assertEquals($missionContent, $mission->getContent()); + } + + public function testExecuteThrowsExceptionWhenPlayerHasNoRoom(): void + { + $author = $this->make(Player::class, [ + 'getRoom' => Expected::once(null), + ]); + + $missionContent = 'Test mission content'; + + $this->missionRepository->store(Argument::any())->shouldNotBeCalled(); + $this->persistenceAdapter->flush()->shouldNotBeCalled(); + + $this->expectException(KillerBadRequestHttpException::class); + $this->expectExceptionMessage('CAN_NOT_ADD_MISSIONS'); + + $this->createMissionUseCase->execute($missionContent, $author); + } + + public function testExecuteThrowsExceptionWhenRoomNotPending(): void + { + $room = $this->make(Room::class, [ + 'getStatus' => Expected::atLeastOnce(Room::IN_GAME), + ]); + + $author = $this->make(Player::class, [ + 'getRoom' => Expected::atLeastOnce($room), + ]); + + $missionContent = 'Test mission content'; + + $this->missionRepository->store(Argument::any())->shouldNotBeCalled(); + $this->persistenceAdapter->flush()->shouldNotBeCalled(); + + $this->expectException(KillerBadRequestHttpException::class); + $this->expectExceptionMessage('CAN_NOT_ADD_MISSIONS'); + + $this->createMissionUseCase->execute($missionContent, $author); + } + + public function testExecuteThrowsExceptionWhenRoomIsEnded(): void + { + $room = $this->make(Room::class, [ + 'getStatus' => Expected::atLeastOnce(Room::ENDED), + ]); + + $author = $this->make(Player::class, [ + 'getRoom' => Expected::atLeastOnce($room), + ]); + + $missionContent = 'Test mission content'; + + $this->missionRepository->store(Argument::any())->shouldNotBeCalled(); + $this->persistenceAdapter->flush()->shouldNotBeCalled(); + + $this->expectException(KillerBadRequestHttpException::class); + $this->expectExceptionMessage('CAN_NOT_ADD_MISSIONS'); + + $this->createMissionUseCase->execute($missionContent, $author); + } + + public function testExecuteAssociatesMissionWithAuthor(): void + { + $room = $this->make(Room::class, [ + 'getStatus' => Expected::atLeastOnce(Room::PENDING), + ]); + + $missionAddedToAuthor = false; + $author = $this->make(Player::class, [ + 'getRoom' => Expected::atLeastOnce($room), + 'addAuthoredMission' => Expected::once( + static function (Mission $mission) use (&$missionAddedToAuthor, &$author) { + $missionAddedToAuthor = true; + + return $author; + }, + ), + ]); + + $missionContent = 'Associated mission'; + + $this->missionRepository->store(Argument::type(Mission::class))->shouldBeCalledOnce(); + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + $this->createMissionUseCase->execute($missionContent, $author); + + $this->assertTrue($missionAddedToAuthor); + } + + public function testExecutePersistsMission(): void + { + $room = $this->make(Room::class, [ + 'getStatus' => Expected::atLeastOnce(Room::PENDING), + ]); + + $author = $this->make(Player::class, [ + 'getRoom' => Expected::atLeastOnce($room), + 'addAuthoredMission' => Expected::once(static function ($mission) use (&$author) { + return $author; + }), + ]); + + $missionContent = 'Persisted mission'; + + $capturedMission = null; + $this->missionRepository->store(Argument::type(Mission::class)) + ->will(static function ($args) use (&$capturedMission): void { + $capturedMission = $args[0]; + }) + ->shouldBeCalledOnce(); + + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + $mission = $this->createMissionUseCase->execute($missionContent, $author); + + $this->assertSame($mission, $capturedMission); + $this->assertEquals($missionContent, $capturedMission->getContent()); + } +} diff --git a/tests/Unit/Application/UseCase/Room/CreateRoomUseCaseTest.php b/tests/Unit/Application/UseCase/Room/CreateRoomUseCaseTest.php new file mode 100644 index 0000000..425b1fe --- /dev/null +++ b/tests/Unit/Application/UseCase/Room/CreateRoomUseCaseTest.php @@ -0,0 +1,125 @@ +roomRepository = $this->prophesize(RoomRepository::class); + $this->persistenceAdapter = $this->prophesize(PersistenceAdapterInterface::class); + $this->changeRoomUseCase = $this->prophesize(ChangeRoomUseCase::class); + + $this->createRoomUseCase = new CreateRoomUseCase( + $this->roomRepository->reveal(), + $this->persistenceAdapter->reveal(), + $this->changeRoomUseCase->reveal(), + ); + + parent::setUp(); + } + + public function testExecuteCreatesRegularRoom(): void + { + $player = new Player(); + $player->setName('TestPlayer'); + $roomName = 'Test Room'; + + $this->changeRoomUseCase->execute( + Argument::that(static fn ($p) => $p === $player), + Argument::type(Room::class), + )->shouldBeCalledOnce(); + + $this->roomRepository->store(Argument::type(Room::class))->shouldBeCalledOnce(); + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + $room = $this->createRoomUseCase->execute($player, $roomName, false); + + $this->assertInstanceOf(Room::class, $room); + $this->assertEquals($roomName, $room->getName()); + $this->assertFalse($room->isGameMastered()); + $this->assertContains('ROLE_ADMIN', $player->getRoles()); + $this->assertNotEquals(PlayerStatus::SPECTATING, $player->getStatus()); + } + + public function testExecuteCreatesGameMasteredRoom(): void + { + $player = new Player(); + $player->setName('TestPlayer'); + $roomName = 'Game Master Room'; + + $this->changeRoomUseCase->execute( + Argument::that(static fn ($p) => $p === $player), + Argument::type(Room::class), + )->shouldBeCalledOnce(); + + $this->roomRepository->store(Argument::type(Room::class))->shouldBeCalledOnce(); + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + $room = $this->createRoomUseCase->execute($player, $roomName, true); + + $this->assertInstanceOf(Room::class, $room); + $this->assertEquals($roomName, $room->getName()); + $this->assertTrue($room->isGameMastered()); + $this->assertContains('ROLE_MASTER', $player->getRoles()); + $this->assertEquals(PlayerStatus::SPECTATING, $player->getStatus()); + } + + public function testExecuteSetPlayerAsAdmin(): void + { + $player = new Player(); + $player->setName('TestPlayer'); + $roomName = 'Admin Room'; + + $this->changeRoomUseCase->execute(Argument::cetera())->shouldBeCalledOnce(); + $this->roomRepository->store(Argument::type(Room::class))->shouldBeCalledOnce(); + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + $this->createRoomUseCase->execute($player, $roomName, false); + + $this->assertContains('ROLE_ADMIN', $player->getRoles()); + } + + public function testExecutePersistsRoom(): void + { + $player = new Player(); + $player->setName('TestPlayer'); + $roomName = 'Persist Test Room'; + + $this->changeRoomUseCase->execute(Argument::cetera())->shouldBeCalledOnce(); + + $capturedRoom = null; + $this->roomRepository->store(Argument::type(Room::class)) + ->will(static function ($args) use (&$capturedRoom): void { + $capturedRoom = $args[0]; + }) + ->shouldBeCalledOnce(); + + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + $room = $this->createRoomUseCase->execute($player, $roomName, false); + + $this->assertSame($room, $capturedRoom); + } +} diff --git a/tests/Unit/Application/UseCase/Room/GenerateRoomWithMissionUseCaseTest.php b/tests/Unit/Application/UseCase/Room/GenerateRoomWithMissionUseCaseTest.php new file mode 100644 index 0000000..73c4c85 --- /dev/null +++ b/tests/Unit/Application/UseCase/Room/GenerateRoomWithMissionUseCaseTest.php @@ -0,0 +1,212 @@ +persistenceAdapter = $this->prophesize(PersistenceAdapterInterface::class); + $this->missionGenerator = $this->prophesize(MissionGeneratorInterface::class); + $this->createRoomUseCase = $this->prophesize(CreateRoomUseCase::class); + $this->createMissionUseCase = $this->prophesize(CreateMissionUseCase::class); + + $this->generateRoomWithMissionUseCase = new GenerateRoomWithMissionUseCase( + $this->persistenceAdapter->reveal(), + $this->missionGenerator->reveal(), + $this->createRoomUseCase->reveal(), + $this->createMissionUseCase->reveal(), + ); + } + + public function testExecuteCreatesRoomWithMissions(): void + { + $player = $this->make(Player::class, [ + 'getId' => 1, + 'getName' => 'TestPlayer', + ]); + + $roomName = 'Test AI Room'; + $missionsCount = 5; + $theme = MissionTheme::SPY; + + $room = $this->make(Room::class, [ + 'getName' => $roomName, + 'isGameMastered' => true, + 'getId' => 'ROOM-123', + ]); + + $generatedMissionContents = [ + 'Mission 1 content', + 'Mission 2 content', + 'Mission 3 content', + 'Mission 4 content', + 'Mission 5 content', + ]; + + // Mock the room creation + $this->createRoomUseCase->execute($player, $roomName, true) + ->shouldBeCalledOnce() + ->willReturn($room); + + // Mock the mission generation + $this->missionGenerator->generateMissions($missionsCount, $theme) + ->shouldBeCalledOnce() + ->willReturn($generatedMissionContents); + + // Mock the mission creation for each generated mission + foreach ($generatedMissionContents as $content) { + $mission = new Mission(); + $mission->setContent($content); + + $this->createMissionUseCase->execute($content, $player) + ->shouldBeCalledOnce() + ->willReturn($mission); + } + + // Mock persistence + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + // Execute the use case + $result = $this->generateRoomWithMissionUseCase->execute($roomName, $player, $missionsCount, $theme); + + // Assertions + $this->assertInstanceOf(Room::class, $result); + $this->assertEquals($roomName, $result->getName()); + $this->assertTrue($result->isGameMastered()); + } + + public function testExecuteWithDefaultMissionsCount(): void + { + $player = $this->make(Player::class, [ + 'getId' => 1, + 'getName' => 'TestPlayer', + ]); + + $roomName = 'Test Room'; + + $room = $this->make(Room::class, [ + 'getName' => $roomName, + 'isGameMastered' => true, + 'getId' => 'ROOM-123', + ]); + + $generatedMissionContents = array_fill(0, 10, 'Mission content'); + + $this->createRoomUseCase->execute($player, $roomName, true) + ->shouldBeCalledOnce() + ->willReturn($room); + + // Should use default count of 10 + $this->missionGenerator->generateMissions(10, null) + ->shouldBeCalledOnce() + ->willReturn($generatedMissionContents); + + $this->createMissionUseCase->execute(Argument::any(), $player) + ->shouldBeCalledTimes(10) + ->willReturn(new Mission()); + + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + $result = $this->generateRoomWithMissionUseCase->execute($roomName, $player); + + $this->assertInstanceOf(Room::class, $result); + } + + public function testExecuteWithoutTheme(): void + { + $player = $this->make(Player::class, [ + 'getId' => 1, + 'getName' => 'TestPlayer', + ]); + + $roomName = 'Test Room'; + $missionsCount = 3; + + $room = $this->make(Room::class, [ + 'getName' => $roomName, + 'isGameMastered' => true, + 'getId' => 'ROOM-123', + ]); + + $generatedMissionContents = [ + 'Mission 1 content', + 'Mission 2 content', + 'Mission 3 content', + ]; + + $this->createRoomUseCase->execute($player, $roomName, true) + ->shouldBeCalledOnce() + ->willReturn($room); + + // Should pass null for theme + $this->missionGenerator->generateMissions($missionsCount, null) + ->shouldBeCalledOnce() + ->willReturn($generatedMissionContents); + + $this->createMissionUseCase->execute(Argument::any(), $player) + ->shouldBeCalledTimes(3) + ->willReturn(new Mission()); + + $this->persistenceAdapter->flush()->shouldBeCalledOnce(); + + $result = $this->generateRoomWithMissionUseCase->execute($roomName, $player, $missionsCount); + + $this->assertInstanceOf(Room::class, $result); + } + + public function testExecuteThrowsExceptionOnFailure(): void + { + $player = $this->make(Player::class, [ + 'getId' => 1, + 'getName' => 'TestPlayer', + ]); + + $roomName = 'Test Room'; + + $room = $this->make(Room::class, [ + 'getName' => $roomName, + 'getId' => 'ROOM-123', + ]); + + $this->createRoomUseCase->execute($player, $roomName, true) + ->shouldBeCalledOnce() + ->willReturn($room); + + // Mock a failure in mission generation + $this->missionGenerator->generateMissions(Argument::any(), Argument::any()) + ->shouldBeCalledOnce() + ->willThrow(new \RuntimeException('AI generation failed')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to create game-mastered room'); + + $this->generateRoomWithMissionUseCase->execute($roomName, $player); + } +}