From 6fc0c0566697f117ab5b478d45ee68d84512cffa Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 19 Dec 2024 21:27:03 -0500 Subject: [PATCH] feat(multiset): send sets to emulators (#2857) --- .../Actions/BuildClientPatchDataAction.php | 338 ++++++++ .../Actions/GetClientSupportLevelAction.php | 18 + ...njectPatchClientSupportLevelDataAction.php | 79 ++ .../Actions/ResolveAchievementSetsAction.php | 2 +- app/Helpers/database/game.php | 152 ---- database/factories/GameFactory.php | 1 + public/dorequest.php | 36 +- .../BuildClientPatchDataActionTest.php | 813 ++++++++++++++++++ .../ResolveAchievementSetsActionTest.php | 40 +- tests/Feature/Connect/PatchDataTest.php | 1 + 10 files changed, 1306 insertions(+), 174 deletions(-) create mode 100644 app/Connect/Actions/BuildClientPatchDataAction.php create mode 100644 app/Connect/Actions/GetClientSupportLevelAction.php create mode 100644 app/Connect/Actions/InjectPatchClientSupportLevelDataAction.php rename app/{Platform => Connect}/Actions/ResolveAchievementSetsAction.php (99%) create mode 100644 tests/Feature/Connect/Actions/BuildClientPatchDataActionTest.php rename tests/Feature/{Platform/Action => Connect/Actions}/ResolveAchievementSetsActionTest.php (95%) diff --git a/app/Connect/Actions/BuildClientPatchDataAction.php b/app/Connect/Actions/BuildClientPatchDataAction.php new file mode 100644 index 0000000000..90b24957c4 --- /dev/null +++ b/app/Connect/Actions/BuildClientPatchDataAction.php @@ -0,0 +1,338 @@ +buildPatchData($game, null, $user, $flag); + } + + $hashGame = $gameHash->game; + + // If there's no user or the current user has multiset globally disabled, use the hash game. + if (!$user || $user->is_globally_opted_out_of_subsets) { + return $this->buildPatchData($hashGame, null, $user, $flag); + } + + // Resolve sets once - we'll use this for building the full patch data. + $resolvedSets = (new ResolveAchievementSetsAction())->execute($gameHash, $user); + if ($resolvedSets->isEmpty()) { + return $this->buildPatchData($hashGame, null, $user, $flag); + } + + // Get the core game from the first resolved set. + $coreSet = $resolvedSets->first(); + $coreGame = Game::find($coreSet->game_id) ?? $hashGame; + + $richPresencePatch = $coreGame->RichPresencePatch; + + // Look up if this hash game's achievement set is attached as a subset to the core game + $hashGameSubsetAttachment = GameAchievementSet::where('game_id', $coreGame->id) + ->where('achievement_set_id', $hashGame->gameAchievementSets()->core()->first()?->achievement_set_id) + ->first(); + + if ($hashGameSubsetAttachment && in_array($hashGameSubsetAttachment->type, [AchievementSetType::Specialty, AchievementSetType::Exclusive])) { + /** + * At the root level: + * - Use the subset game's ID and achievements. + * - Use the core game's title and image. + * - Use the subset game's RP, if present. + */ + $richPresencePatch = $hashGame->RichPresencePatch ?: $richPresencePatch; + + return $this->buildPatchData( + $hashGame, // ... use the subset game for ID and achievements ... + $resolvedSets, + $user, + $flag, + $richPresencePatch, + $coreGame // ... use the core game for title and image ... + ); + } + + return $this->buildPatchData($coreGame, $resolvedSets, $user, $flag, $richPresencePatch); + } + + /** + * @param Game $game The game to build root-level data for + * @param Collection|null $resolvedSets The sets to send to the client/emulator + * @param User|null $user The current user requesting the patch data (for player count calculations) + * @param AchievementFlag|null $flag Optional flag to filter the achievements by (eg: only official achievements) + * @param string|null $richPresencePatch The RP patch code that the client should use + * @param Game|null $titleGame Optional game to use for title and image (for specialty/exclusive sets) + */ + private function buildPatchData( + Game $game, + ?Collection $resolvedSets, + ?User $user, + ?AchievementFlag $flag, + ?string $richPresencePatch = null, + ?Game $titleGame = null + ): array { + $gamePlayerCount = $this->calculateGamePlayerCount($game, $user); + + $coreAchievementSet = GameAchievementSet::where('game_id', $game->id) + ->core() + ->with('achievementSet.achievements.developer') + ->first(); + + // Don't fetch the games in the loop if we have sets. Grab them all in a single query. + $sets = []; + if ($resolvedSets?->isNotEmpty()) { + $coreGameIds = $resolvedSets->pluck('core_game_id')->unique(); + $achievementSetIds = $resolvedSets->pluck('achievement_set_id')->unique(); + + // Preload all games. + $games = Game::whereIn('ID', $coreGameIds)->get()->keyBy('ID'); + + // Preload all GameAchievementSet entities we'll need. + $gameAchievementSets = GameAchievementSet::where(function ($query) use ($game, $achievementSetIds) { + $query->where('game_id', $game->id)->whereIn('achievement_set_id', $achievementSetIds); + })->orWhere(function ($query) use ($resolvedSets) { + $query + ->whereIn('game_id', $resolvedSets->pluck('game_id')) + ->whereIn('achievement_set_id', $resolvedSets->pluck('achievement_set_id')); + })->get(); + + foreach ($resolvedSets as $resolvedSet) { + // We don't want to include sets in the list that are duplicative + // with the root-level data in the response (for achievements & leaderboards). + if ($resolvedSet->game_id === $game->id && $resolvedSet->type === AchievementSetType::Core) { + continue; + } + + // For specialty/exclusive sets, instead of looking up how this game's + // achievement set is attached, look up how the resolved set's achievement + // set is attached to its parent. + $setAttachment = $gameAchievementSets->first(function ($attachment) use ($resolvedSet) { + return + $attachment->game_id === $resolvedSet->game_id + && $attachment->achievement_set_id === $resolvedSet->achievement_set_id + ; + }); + + // Get the achievement set for the current game. + $gameAchievementSet = $gameAchievementSets->first(function ($attachment) use ($game, $resolvedSet) { + return + $attachment->game_id === $game->id + && $attachment->achievement_set_id === $resolvedSet->achievement_set_id + ; + }); + + // Skip if this is a specialty/exclusive set that we're directly loading a hash for. + if ( + $setAttachment + && in_array($setAttachment->type, [AchievementSetType::Specialty, AchievementSetType::Exclusive]) + && $gameAchievementSet !== null + ) { + continue; + } + + // Get the achievements for this set. If there are no published + // achievements, we won't bother sending the set to the client. + $achievements = $this->buildAchievementsData($resolvedSet, $gamePlayerCount, $flag); + if (empty($achievements)) { + continue; + } + + $setGame = $games[$resolvedSet->core_game_id]; + $sets[] = [ + 'GameAchievementSetID' => $resolvedSet->id, + 'SetTitle' => $resolvedSet->title, + 'Type' => $resolvedSet->type->value, + 'ImageIcon' => $setGame->ImageIcon, + 'ImageIconURL' => media_asset($setGame->ImageIcon), + 'Achievements' => $achievements, + 'Leaderboards' => $this->buildLeaderboardsData($setGame), + ]; + } + } + + return [ + 'Success' => true, + 'PatchData' => [ + ...$this->buildBaseGameData($game, $richPresencePatch, $titleGame), + 'Achievements' => $coreAchievementSet + ? $this->buildAchievementsData($coreAchievementSet, $gamePlayerCount, $flag) + : [], + 'Leaderboards' => $this->buildLeaderboardsData($game), + ...(!empty($sets) ? ['Sets' => $sets] : []), + ], + ]; + } + + /** + * Builds achievement information needed by emulators. + * + * @param GameAchievementSet $gameAchievementSet The achievement set to build achievement data for + * @param int $gamePlayerCount The total number of players (minimum of 1 to prevent division by zero) + * @param AchievementFlag|null $flag Optional flag to filter the achievements by (eg: only official achievements) + */ + private function buildAchievementsData( + GameAchievementSet $gameAchievementSet, + int $gamePlayerCount, + ?AchievementFlag $flag, + ): array { + /** @var Collection $achievements */ + $achievements = $gameAchievementSet->achievementSet + ->achievements() + ->with('developer') + ->orderBy('DisplayOrder') // explicit display order + ->orderBy('ID') // tiebreaker on creation sequence + ->get(); + + if ($flag) { + $achievements = $achievements->where('Flags', '=', $flag->value); + } + + $achievementsData = []; + + foreach ($achievements as $achievement) { + // If an achievement has an invalid flag, skip it. + if (!AchievementFlag::tryFrom($achievement->Flags)) { + continue; + } + + // Calculate rarity assuming it will be used when the player unlocks the achievement, + // which implies they haven't already unlocked it. + $rarity = min(100.0, round((float) ($achievement->unlocks_total + 1) * 100 / $gamePlayerCount, 2)); + $rarityHardcore = min(100.0, round((float) ($achievement->unlocks_hardcore_total + 1) * 100 / $gamePlayerCount, 2)); + + $achievementsData[] = [ + 'ID' => $achievement->id, + 'MemAddr' => $achievement->MemAddr, + 'Title' => $achievement->title, + 'Description' => $achievement->description, + 'Points' => $achievement->points, + 'Author' => $achievement->developer->display_name ?? '', + 'Modified' => $achievement->DateModified->unix(), + 'Created' => $achievement->DateCreated->unix(), + 'BadgeName' => $achievement->BadgeName, + 'Flags' => $achievement->Flags, + 'Type' => $achievement->type, + 'Rarity' => $rarity, + 'RarityHardcore' => $rarityHardcore, + 'BadgeURL' => $achievement->badge_unlocked_url, + 'BadgeLockedURL' => $achievement->badge_locked_url, + ]; + } + + return $achievementsData; + } + + /** + * Builds the basic game information needed by emulators. + */ + private function buildBaseGameData(Game $game, ?string $richPresencePatch, ?Game $titleGame): array + { + // If a title game is provided, use its title and image. + $titleGame = $titleGame ?? $game; + + return [ + 'ID' => $game->id, + 'Title' => $titleGame->title, + 'ImageIcon' => $titleGame->ImageIcon, + 'RichPresencePatch' => $richPresencePatch ?? $game->RichPresencePatch, + 'ConsoleID' => $game->ConsoleID, + 'ImageIconURL' => media_asset($titleGame->ImageIcon), + ]; + } + + /** + * Builds leaderboard information needed by emulators. + */ + private function buildLeaderboardsData(Game $game): array + { + $leaderboardsData = []; + + // TODO detach leaderboards from games + $leaderboards = $game->leaderboards() + ->orderBy('DisplayOrder') // explicit display order + ->orderBy('ID') // tiebreaker on creation sequence + ->get(); + + foreach ($leaderboards as $leaderboard) { + $leaderboardsData[] = [ + 'ID' => $leaderboard->id, + 'Mem' => $leaderboard->Mem, + 'Format' => $leaderboard->Format, + 'LowerIsBetter' => $leaderboard->LowerIsBetter, + 'Title' => $leaderboard->title, + 'Description' => $leaderboard->Description, + 'Hidden' => ($leaderboard->DisplayOrder < 0), + ]; + } + + return $leaderboardsData; + } + + /** + * Calculates the total number of players for the game, which ultimately gets used in + * achievement rarity calculations. + * + * This method adds 1 to the total if the requesting user hasn't played the game yet, + * which ensures accurate rarity predictions for when they unlock achievements. + * + * @param Game $game The game to calculate player count for + * @param User|null $user The current user requesting the data + * + * @return int The total number of players (minimum of 1 to prevent division by zero) + */ + private function calculateGamePlayerCount(Game $game, ?User $user): int + { + $gamePlayerCount = $game->players_total; + + if ($user) { + $hasPlayerGame = PlayerGame::whereUserId($user->id) + ->whereGameId($game->id) + ->exists(); + + if (!$hasPlayerGame) { + $gamePlayerCount++; + } + } + + return max(1, $gamePlayerCount); + } +} diff --git a/app/Connect/Actions/GetClientSupportLevelAction.php b/app/Connect/Actions/GetClientSupportLevelAction.php new file mode 100644 index 0000000000..34cdbefefe --- /dev/null +++ b/app/Connect/Actions/GetClientSupportLevelAction.php @@ -0,0 +1,18 @@ +getSupportLevel($userAgent); + } +} diff --git a/app/Connect/Actions/InjectPatchClientSupportLevelDataAction.php b/app/Connect/Actions/InjectPatchClientSupportLevelDataAction.php new file mode 100644 index 0000000000..304344633f --- /dev/null +++ b/app/Connect/Actions/InjectPatchClientSupportLevelDataAction.php @@ -0,0 +1,79 @@ +game ?? $game; + $canAddWarningAchievement = $coreGame->achievements_published < 0; // will never be true. change to > when ready + + if ($clientSupportLevel !== ClientSupportLevel::Full && $canAddWarningAchievement) { + // We intentionally place the warning achievement at the top of the list. + $constructedPatchData['Achievements'] = [ + $this->buildClientSupportWarningAchievement($clientSupportLevel), + ...$constructedPatchData['Achievements'], + ]; + } + + if ($clientSupportLevel === ClientSupportLevel::Unknown) { + $constructedPatchData['Warning'] = 'The server does not recognize this client and will not allow hardcore unlocks. Please send a message to RAdmin on the RetroAchievements website for information on how to submit your emulator for hardcore consideration.'; + } + + return $constructedPatchData; + } + + /** + * This warning achievement should appear at the top of the emulator's achievements + * list. It should automatically unlock after a few seconds of patch data retrieval. + * The intention is to notify a user that they are using an outdated client + * and need to update, as well as what the repercussions of their continued + * play session with their current client might be. + */ + private function buildClientSupportWarningAchievement(ClientSupportLevel $clientSupportLevel): array + { + return [ + 'ID' => Achievement::CLIENT_WARNING_ID, + 'MemAddr' => '1=1.300.', // pop after 5 seconds + 'Title' => ($clientSupportLevel === ClientSupportLevel::Outdated) ? + 'Warning: Outdated Emulator (please update)' : 'Warning: Unknown Emulator', + 'Description' => 'Hardcore unlocks cannot be earned using this emulator.', + 'Points' => 0, + 'Author' => '', + 'Modified' => Carbon::now()->unix(), + 'Created' => Carbon::now()->unix(), + 'BadgeName' => '00000', + 'Flags' => AchievementFlag::OfficialCore->value, + 'Type' => null, + 'Rarity' => 0.0, + 'RarityHardcore' => 0.0, + 'BadgeURL' => media_asset("Badge/00000.png"), + 'BadgeLockedURL' => media_asset("Badge/00000_lock.png"), + ]; + } +} diff --git a/app/Platform/Actions/ResolveAchievementSetsAction.php b/app/Connect/Actions/ResolveAchievementSetsAction.php similarity index 99% rename from app/Platform/Actions/ResolveAchievementSetsAction.php rename to app/Connect/Actions/ResolveAchievementSetsAction.php index 98eb38a2ce..bb5b178fbd 100644 --- a/app/Platform/Actions/ResolveAchievementSetsAction.php +++ b/app/Connect/Actions/ResolveAchievementSetsAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Platform\Actions; +namespace App\Connect\Actions; use App\Models\GameAchievementSet; use App\Models\GameHash; diff --git a/app/Helpers/database/game.php b/app/Helpers/database/game.php index 6988e967a0..698b93a707 100644 --- a/app/Helpers/database/game.php +++ b/app/Helpers/database/game.php @@ -1,21 +1,15 @@ getSupportLevel(request()->header('User-Agent')); - if ($clientSupportLevel === ClientSupportLevel::Blocked) { - return [ - 'Status' => 403, - 'Success' => false, - 'Error' => 'This client is not supported', - ]; - } - - $game = Game::find($gameID); - if (!$game) { - return [ - 'Success' => false, - 'Error' => 'Unknown game', - 'Status' => 404, - 'Code' => 'not_found', - ]; - } - - $gameData = [ - 'ID' => $game->ID, - 'Title' => $game->Title, - 'ImageIcon' => $game->ImageIcon, - 'RichPresencePatch' => $game->RichPresencePatch, - 'ConsoleID' => $game->ConsoleID, - 'ImageIconURL' => media_asset($game->ImageIcon), - 'Achievements' => [], - 'Leaderboards' => [], - ]; - - if ($clientSupportLevel !== ClientSupportLevel::Full) { - if ($game->achievements_published < 0) { // will never be true. change to > when ready - $gameData['Achievements'][] = [ - 'ID' => Achievement::CLIENT_WARNING_ID, - 'MemAddr' => '1=1.300.', // pop after 5 seconds - 'Title' => ($clientSupportLevel === ClientSupportLevel::Outdated) ? - 'Warning: Outdated Emulator (please update)' : 'Warning: Unknown Emulator', - 'Description' => 'Hardcore unlocks cannot be earned using this emulator.', - 'Points' => 0, - 'Author' => '', - 'Modified' => Carbon::now()->unix(), - 'Created' => Carbon::now()->unix(), - 'BadgeName' => '00000', - 'Flags' => AchievementFlag::OfficialCore->value, - 'Type' => null, - 'Rarity' => 0.0, - 'RarityHardcore' => 0.0, - 'BadgeURL' => media_asset("Badge/00000.png"), - 'BadgeLockedURL' => media_asset("Badge/00000_lock.png"), - ]; - } - } - - $gamePlayers = $game->players_total; - if ($user) { - // if the user isn't already tallied in the players for the game, - // adjust the count now for the rarity calculations. - $hasPlayerGame = PlayerGame::where('user_id', $user->id) - ->where('game_id', $game->id) - ->exists(); - if (!$hasPlayerGame) { - $gamePlayers++; - } - } - $gamePlayers = max(1, $gamePlayers); // Prevent divide by zero error if the game has never been played before. - - // Attempt to retrieve the game's core achievement set. - $coreAchievementSet = GameAchievementSet::where('game_id', $game->id) - ->core() - ->with('achievementSet.achievements.developer') - ->first(); - - // If the core achievement set exists, process the achievements. - if ($coreAchievementSet?->achievementSet) { - $achievements = $coreAchievementSet->achievementSet - ->achievements() - ->with('developer') - ->orderBy('DisplayOrder') // explicit display order - ->orderBy('ID') // tiebreaker on creation sequence - ->get(); - - if ($flag != 0) { - $achievements = $achievements->where('Flags', '=', $flag); - } - - foreach ($achievements as $achievement) { - if (!AchievementFlag::tryFrom($achievement->Flags)) { - continue; - } - - // calculate rarity assuming it will be used when the player unlocks the achievement, - // which implies they haven't already unlocked it. - $rarity = min(100.0, round((float) ($achievement->unlocks_total + 1) * 100 / $gamePlayers, 2)); - $rarityHardcore = min(100.0, round((float) ($achievement->unlocks_hardcore_total + 1) * 100 / $gamePlayers, 2)); - - $gameData['Achievements'][] = [ - 'ID' => $achievement->ID, - 'MemAddr' => $achievement->MemAddr, - 'Title' => $achievement->Title, - 'Description' => $achievement->Description, - 'Points' => $achievement->Points, - 'Author' => $achievement->developer->User ?? '', - 'Modified' => $achievement->DateModified->unix(), - 'Created' => $achievement->DateCreated->unix(), - 'BadgeName' => $achievement->BadgeName, - 'Flags' => $achievement->Flags, - 'Type' => $achievement->type, - 'Rarity' => $rarity, - 'RarityHardcore' => $rarityHardcore, - 'BadgeURL' => media_asset("Badge/{$achievement->BadgeName}.png"), - 'BadgeLockedURL' => media_asset("Badge/{$achievement->BadgeName}_lock.png"), - ]; - } - } - - $leaderboards = $game->leaderboards() - ->orderBy('DisplayOrder') // explicit display order - ->orderBy('ID'); // tiebreaker on creation sequence - - foreach ($leaderboards->get() as $leaderboard) { - $gameData['Leaderboards'][] = [ - 'ID' => $leaderboard->ID, - 'Mem' => $leaderboard->Mem, - 'Format' => $leaderboard->Format, - 'LowerIsBetter' => $leaderboard->LowerIsBetter, - 'Title' => $leaderboard->Title, - 'Description' => $leaderboard->Description, - 'Hidden' => ($leaderboard->DisplayOrder < 0), - ]; - } - - $result = [ - 'Success' => true, - 'PatchData' => $gameData, - ]; - - if ($clientSupportLevel === ClientSupportLevel::Unknown) { - $result['Warning'] = 'The server does not recognize this client and will not allow hardcore unlocks. Please send a message to RAdmin on the RetroAchievements website for information on how to submit your emulator for hardcore consideration.'; - } - - return $result; -} diff --git a/database/factories/GameFactory.php b/database/factories/GameFactory.php index 8979e1db0c..c956816939 100644 --- a/database/factories/GameFactory.php +++ b/database/factories/GameFactory.php @@ -30,6 +30,7 @@ public function definition(): array 'sort_title' => null, 'ConsoleID' => 0, 'ImageIcon' => '/Images/000001.png', + 'RichPresencePatch' => fake()->words(10, true), ]; } diff --git a/public/dorequest.php b/public/dorequest.php index d38b868804..327211eb8a 100644 --- a/public/dorequest.php +++ b/public/dorequest.php @@ -1,6 +1,9 @@ input('f', 0); - $response = GetPatchData($gameID, $user, $flag); + $gameHashMd5 = request()->input('m'); + + $clientSupportLevel = (new GetClientSupportLevelAction())->execute(request()->header('User-Agent')); + + // TODO middleware? + if ($clientSupportLevel === ClientSupportLevel::Blocked) { + return DoRequestError('This client is not supported', 403, 'unsupported_client'); + } + + try { + $gameHash = $gameHashMd5 ? GameHash::whereMd5($gameHashMd5)->first() : null; + $game = $gameHashMd5 ? null : Game::find($gameID); + + $response = (new BuildClientPatchDataAction())->execute( + gameHash: $gameHash, + game: $game, + user: $user, + flag: AchievementFlag::tryFrom($flag), + ); + + // Based on the user's current client support level, we may want to attach + // some metadata into the patch response. We'll do that as part of a separate + // action to keep the original data construction pure. + $response = (new InjectPatchClientSupportLevelDataAction())->execute( + $response, + $clientSupportLevel, + $gameHash, + $game, + ); + } catch (InvalidArgumentException $e) { + return DoRequestError('Unknown game', 404, 'not_found'); + } break; case "postactivity": diff --git a/tests/Feature/Connect/Actions/BuildClientPatchDataActionTest.php b/tests/Feature/Connect/Actions/BuildClientPatchDataActionTest.php new file mode 100644 index 0000000000..c845c6a8bc --- /dev/null +++ b/tests/Feature/Connect/Actions/BuildClientPatchDataActionTest.php @@ -0,0 +1,813 @@ +system = System::factory()->create(['ID' => 1, 'Name' => 'NES/Famicom']); + + $this->upsertGameCoreSetAction = new UpsertGameCoreAchievementSetFromLegacyFlagsAction(); + $this->associateAchievementSetToGameAction = new AssociateAchievementSetToGameAction(); + } + + /** + * Helper method to quickly create a game with achievements. + */ + private function createGameWithAchievements( + System $system, + string $title, + int $publishedCount, + int $unpublishedCount = 0, + string $imagePath = '/Images/000011.png', + ?string $richPresencePatch = "Display:\nTest", + ): Game { + $game = Game::factory()->create([ + 'Title' => $title, + 'ConsoleID' => $system->id, + 'ImageIcon' => $imagePath, + 'RichPresencePatch' => $richPresencePatch, + ]); + + Achievement::factory()->published()->count($publishedCount)->create(['GameID' => $game->id]); + Achievement::factory()->count($unpublishedCount)->create(['GameID' => $game->id]); + + return $game; + } + + /** + * Helper method to verify the base game data structure in patch data. + */ + private function assertBaseGameData(array $patchData, Game $game): void + { + $this->assertEquals($game->id, $patchData['ID']); + $this->assertEquals($game->title, $patchData['Title']); + $this->assertEquals($game->ConsoleID, $patchData['ConsoleID']); + $this->assertEquals($game->ImageIcon, $patchData['ImageIcon']); + $this->assertEquals($game->RichPresencePatch, $patchData['RichPresencePatch']); + $this->assertEquals(media_asset($game->ImageIcon), $patchData['ImageIconURL']); + } + + /** + * Helper method to verify achievement data structure and contents. + */ + private function assertAchievementData(array $achievementData, Achievement $achievement, float $expectedRarity, float $expectedRarityHardcore): void + { + $this->assertEquals($achievement->id, $achievementData['ID']); + $this->assertEquals($achievement->title, $achievementData['Title']); + $this->assertEquals($achievement->description, $achievementData['Description']); + $this->assertEquals($achievement->MemAddr, $achievementData['MemAddr']); + $this->assertEquals($achievement->points, $achievementData['Points']); + $this->assertEquals($achievement->developer->display_name ?? '', $achievementData['Author']); + $this->assertEquals($achievement->DateModified->unix(), $achievementData['Modified']); + $this->assertEquals($achievement->DateCreated->unix(), $achievementData['Created']); + $this->assertEquals($achievement->BadgeName, $achievementData['BadgeName']); + $this->assertEquals($achievement->Flags, $achievementData['Flags']); + $this->assertEquals($achievement->type, $achievementData['Type']); + $this->assertEquals($expectedRarity, $achievementData['Rarity']); + $this->assertEquals($expectedRarityHardcore, $achievementData['RarityHardcore']); + $this->assertEquals($achievement->badge_unlocked_url, $achievementData['BadgeURL']); + $this->assertEquals($achievement->badge_locked_url, $achievementData['BadgeLockedURL']); + } + + public function testItThrowsExceptionWhenNoGameOrHashProvided(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Either gameHash or game must be provided to build patch data.'); + + (new BuildClientPatchDataAction())->execute(); + } + + public function testItReturnsBaseGameDataWithNoAchievements(): void + { + // Arrange + $game = Game::factory()->create([ + 'ConsoleID' => $this->system->id, + 'ImageIcon' => '/Images/000011.png', + 'RichPresencePatch' => "Display:\nTest", + ]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(game: $game); + + // Assert + $this->assertTrue($result['Success']); + $this->assertBaseGameData($result['PatchData'], $game); + $this->assertEmpty($result['PatchData']['Achievements']); + $this->assertEmpty($result['PatchData']['Leaderboards']); + } + + public function testItCalculatesRarityForNewPlayer(): void + { + // Arrange + $game = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 1); + $this->upsertGameCoreSetAction->execute($game); + + $achievement = Achievement::where('GameID', $game->id)->first(); + $achievement->unlocks_total = 49; + $achievement->unlocks_hardcore_total = 24; + $achievement->save(); + + $game->players_total = 100; + $game->save(); + + $user = User::factory()->create(); + + // Act + $result = (new BuildClientPatchDataAction())->execute(game: $game, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertCount(1, $result['PatchData']['Achievements']); + + // For a new player, rarity calculation should use players_total + 1. + // Rarity = (unlocks + 1) / (players + 1) * 100 + $this->assertAchievementData( + $result['PatchData']['Achievements'][0], + $achievement, + 49.50, // (49 + 1) / 101 * 100 + 24.75 // (24 + 1) / 101 * 100 + ); + } + + public function testItCalculatesRarityForExistingPlayer(): void + { + // Arrange + $game = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 1); + $this->upsertGameCoreSetAction->execute($game); + + $achievement = Achievement::where('GameID', $game->id)->first(); + $achievement->unlocks_total = 49; + $achievement->unlocks_hardcore_total = 24; + $achievement->save(); + + $game->players_total = 100; + $game->save(); + + $user = User::factory()->create(); + PlayerGame::factory()->create([ + 'user_id' => $user->id, + 'game_id' => $game->id, + ]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(game: $game, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertCount(1, $result['PatchData']['Achievements']); + + // For an existing player, use the actual players_total value. + // Rarity = (unlocks + 1) / players * 100 + $this->assertAchievementData( + $result['PatchData']['Achievements'][0], + $achievement, + 50.00, // (49 + 1) / 100 * 100 + 25.00 // (24 + 1) / 100 * 100 + ); + } + + public function testItHandlesZeroPlayerCountCorrectly(): void + { + // Arrange + $game = $this->createGameWithAchievements($this->system, 'Zero Player Game', publishedCount: 1); + $this->upsertGameCoreSetAction->execute($game); + + $achievement = Achievement::firstWhere('GameID', $game->id); + $achievement->unlocks_total = 0; + $achievement->unlocks_hardcore_total = 0; + $achievement->save(); + + $game->players_total = 0; // !! + $game->save(); + + $user = User::factory()->create(); + + // Act + $result = (new BuildClientPatchDataAction())->execute(game: $game, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertCount(1, $result['PatchData']['Achievements']); + + $this->assertAchievementData( + $result['PatchData']['Achievements'][0], + $achievement, + 100.00, // (0 + 1) / 1 * 100, capped at 100 + 100.00 // (0 + 1) / 1 * 100, capped at 100 + ); + } + + public function testItIncludesLeaderboardData(): void + { + // Arrange + $game = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 1); + + $leaderboard1 = Leaderboard::factory()->create([ + 'GameID' => $game->id, + 'DisplayOrder' => 0, + 'Format' => 'SCORE', + ]); + $leaderboard2 = Leaderboard::factory()->create([ + 'GameID' => $game->id, + 'DisplayOrder' => -1, // !! hidden + 'Format' => 'VALUE', + ]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(game: $game); + + // Assert + $this->assertTrue($result['Success']); + $this->assertCount(2, $result['PatchData']['Leaderboards']); + + $leaderboardData = $result['PatchData']['Leaderboards']; + + // ... hidden leaderboards should always come first ... + $this->assertEquals($leaderboard2->id, $leaderboardData[0]['ID']); + $this->assertEquals($leaderboard2->title, $leaderboardData[0]['Title']); + $this->assertTrue($leaderboardData[0]['Hidden']); + + $this->assertEquals($leaderboard1->id, $leaderboardData[1]['ID']); + $this->assertEquals($leaderboard1->title, $leaderboardData[1]['Title']); + $this->assertFalse($leaderboardData[1]['Hidden']); + } + + public function testItFiltersAchievementsByFlag(): void + { + // Arrange + $game = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 2, + unpublishedCount: 1 + ); + $this->upsertGameCoreSetAction->execute($game); + + // Act + $result = (new BuildClientPatchDataAction())->execute( + game: $game, + flag: AchievementFlag::OfficialCore // !! + ); + + // Assert + $this->assertTrue($result['Success']); + $this->assertCount(2, $result['PatchData']['Achievements']); + + foreach ($result['PatchData']['Achievements'] as $achievementData) { + $this->assertEquals(AchievementFlag::OfficialCore->value, $achievementData['Flags']); + } + } + + public function testItIncludesMultisetDataWhenUsingGameHash(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 2); + $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Gold Medals]', publishedCount: 1); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($bonusGame); + $this->associateAchievementSetToGameAction->execute($baseGame, $bonusGame, AchievementSetType::Bonus, 'Bonus'); + + $gameHash = GameHash::factory()->create(['game_id' => $baseGame->id]); + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $gameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertArrayHasKey('Sets', $result['PatchData']); + $this->assertCount(1, $result['PatchData']['Sets']); + + // ... verify the core set is at the root level ... + $this->assertCount(2, $result['PatchData']['Achievements']); + + // ... verify the bonus set is in the sets level ... + $bonusSet = $result['PatchData']['Sets'][0]; + $this->assertEquals(AchievementSetType::Bonus->value, $bonusSet['Type']); + $this->assertCount(1, $bonusSet['Achievements']); + } + + public function testItOmitsMultisetDataWhenUsingGameDirectlyLikeLegacyClients(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 2); + $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Gold Medals]', publishedCount: 1); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($bonusGame); + $this->associateAchievementSetToGameAction->execute($baseGame, $bonusGame, AchievementSetType::Bonus, 'Bonus'); + + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute( + game: $baseGame, + gameHash: null, // !! + user: $user + ); + + // Assert + $this->assertTrue($result['Success']); + $this->assertArrayNotHasKey('Sets', $result['PatchData']); + } + + /** + * If the user is globally opted out of subsets and they load a bonus subset + * game's hash, then it's like the user is still living in the pre-multiset + * world. The only set that resolves is the set for the subset game. + */ + public function testGloballyOptedOutOfSubsetsAndLoadedSubsetHash(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 1); + $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', publishedCount: 2); + $bonusGame2 = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus 2]', publishedCount: 3); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($bonusGame); + $this->upsertGameCoreSetAction->execute($bonusGame2); + + $this->associateAchievementSetToGameAction->execute($baseGame, $bonusGame, AchievementSetType::Bonus, 'Bonus'); + $this->associateAchievementSetToGameAction->execute($baseGame, $bonusGame2, AchievementSetType::Bonus, 'Bonus 2'); + + $bonusGameHash = GameHash::factory()->create(['game_id' => $bonusGame->id]); + + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_DISABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute( + gameHash: $bonusGameHash, + user: $user + ); + + // Assert + $this->assertTrue($result['Success']); + + $this->assertEquals(2, $result['PatchData']['ID']); + $this->assertEquals('Dragon Quest III [Subset - Bonus]', $result['PatchData']['Title']); + $this->assertCount(2, $result['PatchData']['Achievements']); + + $this->assertArrayNotHasKey('Sets', $result['PatchData']); + } + + /** + * If the user has multiset enabled, they load a bonus subset game's hash, but are locally + * opted out of that subset, then we treat it like they loaded a core game hash. They'll + * receive the core set and any other bonus sets, but not the set they've opted out of. + */ + public function testLocallyOptedOutOfSubsetsAndLoadedOptedOutSubsetHash(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 1, + imagePath: '/Images/000001.png', + richPresencePatch: 'Foo', + ); + $bonusGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III [Subset - Bonus]', + publishedCount: 2, + imagePath: '/Images/000002.png', + richPresencePatch: 'Bar', + ); + $bonusGame2 = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus 2]', publishedCount: 3); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($bonusGame); + $this->upsertGameCoreSetAction->execute($bonusGame2); + + $this->associateAchievementSetToGameAction->execute($baseGame, $bonusGame, AchievementSetType::Bonus, 'Bonus'); // !! + $this->associateAchievementSetToGameAction->execute($baseGame, $bonusGame2, AchievementSetType::Bonus, 'Bonus 2'); + + $bonusGameHash = GameHash::factory()->create(['game_id' => $bonusGame->id]); + + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // They're going to load a hash for $bonusGame, but they're also locally opted out of + // $bonusGame's achievement set. + $optOutSet = GameAchievementSet::firstWhere('title', 'Bonus'); // !! + UserGameAchievementSetPreference::factory()->create([ + 'user_id' => $user->id, + 'game_achievement_set_id' => GameAchievementSet::whereGameId($baseGame->id) + ->whereType(AchievementSetType::Bonus) + ->whereAchievementSetId($optOutSet->achievement_set_id) + ->first() + ->id, + 'opted_in' => false, + ]); + + // Act + $result = (new BuildClientPatchDataAction())->execute( + gameHash: $bonusGameHash, + user: $user + ); + + // Assert + $this->assertTrue($result['Success']); + + $this->assertEquals($baseGame->id, $result['PatchData']['ID']); + $this->assertEquals($baseGame->title, $result['PatchData']['Title']); + $this->assertEquals($baseGame->ImageIcon, $result['PatchData']['ImageIcon']); + $this->assertCount(1, $result['PatchData']['Achievements']); + + $this->assertEquals($baseGame->RichPresencePatch, $result['PatchData']['RichPresencePatch']); + + $this->assertCount(1, $result['PatchData']['Sets']); + + $this->assertEquals('bonus', $result['PatchData']['Sets'][0]['Type']); + $this->assertEquals('Bonus 2', $result['PatchData']['Sets'][0]['SetTitle']); + $this->assertCount(3, $result['PatchData']['Sets'][0]['Achievements']); + } + + public function testItPrioritizesSpecialtySetRichPresenceScript(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 1, + imagePath: '/Images/000001.png', + richPresencePatch: 'Foo', + ); + $specialtyGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III [Subset - Special]', + publishedCount: 2, + imagePath: '/Images/000002.png', + richPresencePatch: 'Bar', + ); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($specialtyGame); + $this->associateAchievementSetToGameAction->execute($baseGame, $specialtyGame, AchievementSetType::Specialty, 'Special'); + + $specialtyGameHash = GameHash::factory()->create(['game_id' => $specialtyGame->id]); + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $specialtyGameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertEquals($specialtyGame->id, $result['PatchData']['ID']); // ... use subset game's ID ... + $this->assertEquals($specialtyGame->RichPresencePatch, $result['PatchData']['RichPresencePatch']); // ... and specialty RP. + } + + public function testItPrioritizesExclusiveSetRichPresenceScript(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 1, + richPresencePatch: 'Foo', + ); + $exclusiveGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III [Subset - Exclusive]', + publishedCount: 2, + richPresencePatch: 'Bar', + ); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($exclusiveGame); + $this->associateAchievementSetToGameAction->execute($baseGame, $exclusiveGame, AchievementSetType::Exclusive, 'Exclusive'); + + $exclusiveGameHash = GameHash::factory()->create(['game_id' => $exclusiveGame->id]); + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $exclusiveGameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertEquals($exclusiveGame->id, $result['PatchData']['ID']); // ... use subset game's ID ... + $this->assertEquals($exclusiveGame->RichPresencePatch, $result['PatchData']['RichPresencePatch']); // ... but exclusive RP. + } + + public function testItFallsBackToCoreSetRichPresenceScript(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 1, + imagePath: '/Images/000001.png', + richPresencePatch: 'Foo', + ); + $specialtyGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III [Subset - Special]', + publishedCount: 2, + imagePath: '/Images/000002.png', + ); + + $specialtyGame->RichPresencePatch = ""; // !! + $specialtyGame->save(); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($specialtyGame); + $this->associateAchievementSetToGameAction->execute($baseGame, $specialtyGame, AchievementSetType::Specialty, 'Special'); + + $specialtyGameHash = GameHash::factory()->create(['game_id' => $specialtyGame->id]); + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $specialtyGameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertEquals($baseGame->RichPresencePatch, $result['PatchData']['RichPresencePatch']); + } + + public function testItUsesCoreGameRichPresenceForBonusSet(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 1, + richPresencePatch: 'Foo', + ); + $bonusGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III [Subset - Bonus]', + publishedCount: 2, + richPresencePatch: 'Bar', + ); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($bonusGame); + $this->associateAchievementSetToGameAction->execute($baseGame, $bonusGame, AchievementSetType::Bonus, 'Bonus'); + + $bonusGameHash = GameHash::factory()->create(['game_id' => $bonusGame->id]); + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $bonusGameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertEquals($baseGame->RichPresencePatch, $result['PatchData']['RichPresencePatch']); + } + + public function testItResolvesRootDataCorrectlyForSpecialtySet(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 3, + richPresencePatch: 'Foo', + ); + $specialtyGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III [Subset - Special]', + publishedCount: 2, + richPresencePatch: 'Bar', + ); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($specialtyGame); + $this->associateAchievementSetToGameAction->execute($baseGame, $specialtyGame, AchievementSetType::Specialty, 'Special'); + + $specialtyGameHash = GameHash::factory()->create(['game_id' => $specialtyGame->id]); + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $specialtyGameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + + // ... title and image should be from the base game ... + $this->assertEquals($baseGame->title, $result['PatchData']['Title']); + $this->assertEquals($baseGame->ImageIcon, $result['PatchData']['ImageIcon']); + + // ... id and achievements should be from the subset ... + $this->assertEquals($specialtyGame->id, $result['PatchData']['ID']); + $this->assertCount(2, $result['PatchData']['Achievements']); // the subset game's achievements + + // ... RP should be from the specialty game ... + $this->assertEquals($specialtyGame->RichPresencePatch, $result['PatchData']['RichPresencePatch']); + } + + public function testItResolvesRootDataCorrectlyForExclusiveSet(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 3, + richPresencePatch: 'Foo', + ); + $exclusiveGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III [Subset - Exclusive]', + publishedCount: 2, + richPresencePatch: 'Bar', + ); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($exclusiveGame); + $this->associateAchievementSetToGameAction->execute($baseGame, $exclusiveGame, AchievementSetType::Exclusive, 'Exclusive'); + + $exclusiveGameHash = GameHash::factory()->create(['game_id' => $exclusiveGame->id]); + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $exclusiveGameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + + // ... title and image should be from the base game ... + $this->assertEquals($baseGame->title, $result['PatchData']['Title']); + $this->assertEquals($baseGame->ImageIcon, $result['PatchData']['ImageIcon']); + + // ... id and achievements should be from the subset ... + $this->assertEquals($exclusiveGame->id, $result['PatchData']['ID']); + $this->assertCount(2, $result['PatchData']['Achievements']); // the subset game's achievements + + // ... RP should be from the exclusive game ... + $this->assertEquals($exclusiveGame->RichPresencePatch, $result['PatchData']['RichPresencePatch']); + + // ... sets should not be present, as it is duplicative ... + $this->assertArrayNotHasKey('Sets', $result['PatchData']); + } + + public function testItHandlesGameWithNoAchievementSets(): void + { + // Arrange + $game = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 0, + unpublishedCount: 0, + ); + + $gameHash = GameHash::factory()->create(['game_id' => $game->id]); + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $gameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertEmpty($result['PatchData']['Achievements']); + $this->assertEmpty($result['PatchData']['Sets'] ?? []); + $this->assertEquals($game->id, $result['PatchData']['ID']); + $this->assertEquals($game->RichPresencePatch, $result['PatchData']['RichPresencePatch']); + } + + public function testItHandlesSubsetWithNoCoreGame(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 0, + unpublishedCount: 0, + richPresencePatch: 'Foo', + ); + $subsetGame = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III [Subset - Gold Medals]', + publishedCount: 2, + richPresencePatch: 'Bar', + ); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($subsetGame); + $this->associateAchievementSetToGameAction->execute($baseGame, $subsetGame, AchievementSetType::Specialty, 'Gold Medals'); + + $subsetGameHash = GameHash::factory()->create(['game_id' => $subsetGame->id]); + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $subsetGameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + + $this->assertEquals($subsetGame->id, $result['PatchData']['ID']); + $this->assertEquals($subsetGame->RichPresencePatch, $result['PatchData']['RichPresencePatch']); + + $this->assertCount(2, $result['PatchData']['Achievements']); + + // no achievements for the base game and user loaded a subset hash. therefore, no sets. + $this->assertArrayNotHasKey('Sets', $result['PatchData']); + } + + public function testItBuildsPatchDataWithGameHashAndNullUser(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements($this->system, 'Hash Null User Game', publishedCount: 6); + $this->upsertGameCoreSetAction->execute($baseGame); + + $gameHash = GameHash::factory()->create(['game_id' => $baseGame->id]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $gameHash, user: null); + + // Assert + $this->assertTrue($result['Success']); + $this->assertBaseGameData($result['PatchData'], $baseGame); + $this->assertCount(6, $result['PatchData']['Achievements']); + } + + public function testItDoesntCrashFromNullRichPresencePatch(): void + { + // Arrange + $game = $this->createGameWithAchievements( + $this->system, + 'Dragon Quest III', + publishedCount: 6, + richPresencePatch: null, // !! + ); + + $this->upsertGameCoreSetAction->execute($game); + + // Act + $result = (new BuildClientPatchDataAction())->execute(game: $game); + + // Assert + $this->assertTrue($result['Success']); + $this->assertCount(6, $result['PatchData']['Achievements']); + + $this->assertNull($result['PatchData']['RichPresencePatch']); + } + + public function testItResolvesSetTypesForBaseGameHashesCorrectly(): void + { + // Arrange + $baseGame = $this->createGameWithAchievements($this->system, 'Multi-Set Game', publishedCount: 2); + $bonusSet = $this->createGameWithAchievements($this->system, 'Multi-Set Game [Subset - Bonus]', publishedCount: 1); + $bonusSet2 = $this->createGameWithAchievements($this->system, 'Multi-Set Game [Subset - Bonus 2]', publishedCount: 1); + $specialtySet = $this->createGameWithAchievements($this->system, 'Multi-Set Game [Subset - Specialty]', publishedCount: 1); + + $this->upsertGameCoreSetAction->execute($baseGame); + $this->upsertGameCoreSetAction->execute($bonusSet); + $this->upsertGameCoreSetAction->execute($bonusSet2); + $this->upsertGameCoreSetAction->execute($specialtySet); + + $this->associateAchievementSetToGameAction->execute($baseGame, $bonusSet, AchievementSetType::Bonus, 'Bonus'); + $this->associateAchievementSetToGameAction->execute($baseGame, $bonusSet2, AchievementSetType::Bonus, 'Bonus 2'); + $this->associateAchievementSetToGameAction->execute($baseGame, $specialtySet, AchievementSetType::Specialty, 'Specialty'); + + $baseGameHash = GameHash::factory()->create(['game_id' => $baseGame->id]); // !! + $user = User::factory()->create(['websitePrefs' => self::OPT_IN_TO_ALL_SUBSETS_PREF_ENABLED]); + + // Act + $result = (new BuildClientPatchDataAction())->execute(gameHash: $baseGameHash, user: $user); + + // Assert + $this->assertTrue($result['Success']); + $this->assertArrayHasKey('Sets', $result['PatchData']); + $this->assertCount(2, $result['PatchData']['Sets']); // only bonus sets, core is at the root level + + // ... verify core is at the root level ... + $this->assertCount(2, $result['PatchData']['Achievements']); + + $setTypes = array_column($result['PatchData']['Sets'], 'Type'); + $this->assertContains(AchievementSetType::Bonus->value, $setTypes); + $this->assertNotContains(AchievementSetType::Core->value, $setTypes); + $this->assertNotContains(AchievementSetType::Specialty->value, $setTypes); + } +} diff --git a/tests/Feature/Platform/Action/ResolveAchievementSetsActionTest.php b/tests/Feature/Connect/Actions/ResolveAchievementSetsActionTest.php similarity index 95% rename from tests/Feature/Platform/Action/ResolveAchievementSetsActionTest.php rename to tests/Feature/Connect/Actions/ResolveAchievementSetsActionTest.php index c4cbcc44a8..38d2754904 100644 --- a/tests/Feature/Platform/Action/ResolveAchievementSetsActionTest.php +++ b/tests/Feature/Connect/Actions/ResolveAchievementSetsActionTest.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Tests\Feature\Platform\Action; +namespace Tests\Feature\Connect\Actions; +use App\Connect\Actions\ResolveAchievementSetsAction; use App\Models\Achievement; use App\Models\Game; use App\Models\GameAchievementSet; @@ -12,7 +13,6 @@ use App\Models\User; use App\Models\UserGameAchievementSetPreference; use App\Platform\Actions\AssociateAchievementSetToGameAction; -use App\Platform\Actions\ResolveAchievementSetsAction; use App\Platform\Actions\UpsertGameCoreAchievementSetFromLegacyFlagsAction; use App\Platform\Enums\AchievementFlag; use App\Platform\Enums\AchievementSetType; @@ -49,7 +49,7 @@ private function createGameWithAchievements( System $system, string $title, int $publishedCount, - int $unpublishedCount + int $unpublishedCount = 0 ): Game { $game = Game::factory()->create(['Title' => $title, 'ConsoleID' => $system->id]); Achievement::factory()->published()->count($publishedCount)->create(['GameID' => $game->id]); @@ -217,9 +217,9 @@ public function testItReturnsSpecialtySetAndCoreBonusSetsForSpecialtyHash(): voi public function testItAllowsSpecialtySetPlayersToOptOutOfTheCoreSet(): void { // Arrange - $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', 5, 0); - $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', 3, 0); - $specialtyGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Specialty]', 1, 0); + $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', 5); + $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', 3); + $specialtyGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Specialty]', 1); $this->upsertGameCoreSetAction->execute($baseGame); $this->upsertGameCoreSetAction->execute($bonusGame); @@ -255,9 +255,9 @@ public function testItAllowsSpecialtySetPlayersToOptOutOfTheCoreSet(): void public function testItAllowsSpecialtySetPlayersToOptOutOfBonusSets(): void { // Arrange - $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', 5, 0); - $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', 3, 0); - $specialtyGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Specialty]', 1, 0); + $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 5); + $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', publishedCount: 3); + $specialtyGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Specialty]', publishedCount: 1); $this->upsertGameCoreSetAction->execute($baseGame); $this->upsertGameCoreSetAction->execute($bonusGame); @@ -383,9 +383,9 @@ public function testItIncludesSubsetIfUserIsGloballyOptedOutButLocallyOptedIn(): public function testItReturnsExclusiveSetAndNothingElseForExclusiveHash(): void { // Arrange - $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', 5, 0); - $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', 3, 0); - $exclusiveGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Exclusive]', 6, 0); + $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 5); + $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', publishedCount: 3); + $exclusiveGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Exclusive]', publishedCount: 6); $this->upsertGameCoreSetAction->execute($baseGame); $this->upsertGameCoreSetAction->execute($bonusGame); @@ -411,8 +411,8 @@ public function testItReturnsExclusiveSetAndNothingElseForExclusiveHash(): void public function testItExcludesAchievementSetIfHashIsIncompatible(): void { // Arrange - $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', 5, 0); - $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', 3, 0); + $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 5); + $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', publishedCount: 3); $this->upsertGameCoreSetAction->execute($baseGame); $this->upsertGameCoreSetAction->execute($bonusGame); @@ -512,9 +512,9 @@ public function testCoreGameIdIsCorrectlySet(): void public function testGloballyOptedOutOfSubsetsAndLoadedSubsetHash(): void { // Arrange - $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', 1, 0); - $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', 2, 0); - $bonusGame2 = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus 2]', 3, 0); + $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 1); + $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', publishedCount: 2); + $bonusGame2 = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus 2]', publishedCount: 3); $this->upsertGameCoreSetAction->execute($baseGame); $this->upsertGameCoreSetAction->execute($bonusGame); @@ -545,9 +545,9 @@ public function testGloballyOptedOutOfSubsetsAndLoadedSubsetHash(): void public function testLocallyOptedOutOfSubsetsAndLoadedOptedOutSubsetHash(): void { // Arrange - $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', 1, 0); - $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', 2, 0); - $bonusGame2 = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus 2]', 3, 0); + $baseGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III', publishedCount: 1); + $bonusGame = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus]', publishedCount: 2); + $bonusGame2 = $this->createGameWithAchievements($this->system, 'Dragon Quest III [Subset - Bonus 2]', publishedCount: 3); $this->upsertGameCoreSetAction->execute($baseGame); $this->upsertGameCoreSetAction->execute($bonusGame); diff --git a/tests/Feature/Connect/PatchDataTest.php b/tests/Feature/Connect/PatchDataTest.php index 1295cbcea0..d48e10251f 100644 --- a/tests/Feature/Connect/PatchDataTest.php +++ b/tests/Feature/Connect/PatchDataTest.php @@ -470,6 +470,7 @@ public function testUserAgent(): void ->get($this->apiUrl('patch', ['g' => $game->ID])) ->assertStatus(403) ->assertExactJson([ + 'Code' => 'unsupported_client', 'Status' => 403, 'Success' => false, 'Error' => 'This client is not supported',