diff --git a/.env.example b/.env.example index 4695b07404..7721e35b94 100644 --- a/.env.example +++ b/.env.example @@ -154,6 +154,9 @@ AWS_USE_PATH_STYLE_ENDPOINT=false DISCORD_KEY= DISCORD_SECRET= DISCORD_INVITE_ID= +# RABot +DISCORD_GUILD_ID= +DISCORD_RABOT_TOKEN= # public feeds DISCORD_WEBHOOK_ACHIEVEMENTS= DISCORD_WEBHOOK_CLAIMS= diff --git a/app/Actions/FindUserByIdentifierAction.php b/app/Actions/FindUserByIdentifierAction.php new file mode 100644 index 0000000000..666bb970e8 --- /dev/null +++ b/app/Actions/FindUserByIdentifierAction.php @@ -0,0 +1,29 @@ +first(); + } + + return User::query() + ->where(function ($query) use ($identifier) { + $query->where('display_name', $identifier) + ->orWhere('User', $identifier); + }) + ->first(); + } +} diff --git a/app/Community/Actions/ApproveNewDisplayNameAction.php b/app/Community/Actions/ApproveNewDisplayNameAction.php index 1c5dc79e85..17aec8a7e0 100644 --- a/app/Community/Actions/ApproveNewDisplayNameAction.php +++ b/app/Community/Actions/ApproveNewDisplayNameAction.php @@ -4,6 +4,7 @@ namespace App\Community\Actions; +use App\Http\Actions\UpdateDiscordNicknameAction; use App\Models\User; use App\Models\UserUsername; use GuzzleHttp\Client; @@ -14,9 +15,10 @@ class ApproveNewDisplayNameAction public function execute(User $user, UserUsername $changeRequest): void { $oldDisplayName = $user->display_name; + $newDisplayName = $changeRequest->username; // Automatically mark conflicting requests as denied. - UserUsername::where('username', $changeRequest->username) + UserUsername::where('username', $newDisplayName) ->where('id', '!=', $changeRequest->id) ->whereNull('approved_at') ->whereNull('denied_at') @@ -24,12 +26,14 @@ public function execute(User $user, UserUsername $changeRequest): void $changeRequest->update(['approved_at' => now()]); - $user->display_name = $changeRequest->username; + $user->display_name = $newDisplayName; $user->save(); - sendDisplayNameChangeConfirmationEmail($user, $changeRequest->username); + sendDisplayNameChangeConfirmationEmail($user, $newDisplayName); - $this->notifyDiscord($user, $oldDisplayName, $changeRequest->username); + (new UpdateDiscordNicknameAction())->execute($oldDisplayName, $newDisplayName); + + $this->notifyDiscord($user, $oldDisplayName, $newDisplayName); } private function notifyDiscord(User $user, string $oldName, string $newName): void diff --git a/app/Enums/SearchType.php b/app/Enums/SearchType.php index 36607a2e5d..119a97c988 100644 --- a/app/Enums/SearchType.php +++ b/app/Enums/SearchType.php @@ -32,6 +32,8 @@ abstract class SearchType public const UserModerationComment = 12; + public const Hub = 13; + public static function cases(): array { // NOTE: this order determines the order of the items in the 'search in' dropdown @@ -50,6 +52,7 @@ public static function cases(): array self::GameHashComment, self::SetClaimComment, self::UserModerationComment, + self::Hub, ]; } @@ -74,6 +77,7 @@ public static function toString(int $type): string SearchType::UserModerationComment => "User Moderation Comments", SearchType::GameHashComment => "Game Hash Comments", SearchType::SetClaimComment => "Set Claim Comments", + SearchType::Hub => "Hubs", default => "Invalid search type", }; } diff --git a/app/Filament/Resources/GameResource/Pages/Hubs.php b/app/Filament/Resources/GameResource/Pages/Hubs.php index d7947fb696..8badc86e3c 100644 --- a/app/Filament/Resources/GameResource/Pages/Hubs.php +++ b/app/Filament/Resources/GameResource/Pages/Hubs.php @@ -55,11 +55,14 @@ public function table(Table $table): Table ->searchable(query: function (Builder $query, string $search): Builder { return $query->where('game_sets.id', 'like', "%{$search}"); }) - ->sortable(), + ->sortable(query: function (Builder $query, string $direction): Builder { + return $query->orderBy('game_sets.id', $direction); + }), Tables\Columns\TextColumn::make('title') ->label('Title') - ->searchable(), + ->searchable() + ->sortable(), ]) ->filters([ diff --git a/app/Filament/Resources/GameResource/Pages/SimilarGames.php b/app/Filament/Resources/GameResource/Pages/SimilarGames.php index 8505efb499..05d3ea994b 100644 --- a/app/Filament/Resources/GameResource/Pages/SimilarGames.php +++ b/app/Filament/Resources/GameResource/Pages/SimilarGames.php @@ -51,14 +51,22 @@ public function table(Table $table): Table ->label('') ->size(config('media.icon.sm.width')), - Tables\Columns\TextColumn::make('id') + Tables\Columns\TextColumn::make('ID') ->label('ID') - ->sortable() - ->searchable(), + ->sortable(query: function (Builder $query, string $direction): Builder { + return $query->orderBy('GameData.ID', $direction); + }) + ->searchable(query: function (Builder $query, string $search): Builder { + return $query->where('GameData.ID', 'LIKE', "%{$search}%"); + }), Tables\Columns\TextColumn::make('Title') - ->sortable() - ->searchable(), + ->sortable(query: function (Builder $query, string $direction): Builder { + return $query->orderBy('GameData.Title', $direction); + }) + ->searchable(query: function (Builder $query, string $search): Builder { + return $query->where('GameData.Title', 'LIKE', "%{$search}%"); + }), Tables\Columns\TextColumn::make('system') ->label('System') diff --git a/app/Filament/Resources/UserUsernameResource.php b/app/Filament/Resources/UserUsernameResource.php index d29be3dd9e..8b070caca1 100644 --- a/app/Filament/Resources/UserUsernameResource.php +++ b/app/Filament/Resources/UserUsernameResource.php @@ -84,6 +84,27 @@ public static function table(Table $table): Table ->dateTime() ->sortable(), + Tables\Columns\TextColumn::make('previous_usages') + ->label('Previously Used By') + ->state(fn (UserUsername $record): string => empty($record->previous_usages) ? '-' : 'has_users') + ->description(fn (UserUsername $record): ?string => count($record->previous_usages) > 1 + ? "Used by " . count($record->previous_usages) . " different users" + : null + ) + ->formatStateUsing(function (string $state, UserUsername $record): string { + if ($state === '-') { + return '-'; + } + + return collect($record->previous_usages) + ->map(fn ($usage) => "" . $usage['user']->display_name . "" + ) + ->implode(', '); + }) + ->html(), + Tables\Columns\TextColumn::make('status') ->label('Status') ->state(fn (UserUsername $record): string => match (true) { diff --git a/app/Helpers/database/search.php b/app/Helpers/database/search.php index fd447a7501..5289feb319 100644 --- a/app/Helpers/database/search.php +++ b/app/Helpers/database/search.php @@ -3,6 +3,7 @@ use App\Community\Enums\ArticleType; use App\Enums\Permissions; use App\Enums\SearchType; +use App\Platform\Enums\GameSetType; function canSearch(int $searchType, int $permissions): bool { @@ -51,11 +52,30 @@ function performSearch( FROM GameData AS gd LEFT JOIN Achievements AS ach ON ach.GameID = gd.ID AND ach.Flags = 3 LEFT JOIN Console AS c ON gd.ConsoleID = c.ID - WHERE gd.Title LIKE '%$searchQuery%' + WHERE gd.ConsoleID != 100 + AND gd.Title LIKE '%$searchQuery%' GROUP BY gd.ID, gd.Title ORDER BY SecondarySort, REPLACE(gd.Title, '|', ''), gd.Title"; } + if (in_array(SearchType::Hub, $searchType)) { + $counts[] = "SELECT COUNT(*) AS Count FROM game_sets WHERE deleted_at IS NULL AND type = '" . GameSetType::Hub->value . "' AND title LIKE '%$searchQuery%'"; + $parts[] = " + SELECT " . SearchType::Hub . " AS Type, gs.id AS ID, CONCAT('/hub/', gs.id) AS Target, + CONCAT(gs.title, ' (Hub)') AS Title, + CASE + WHEN gs.title LIKE '$searchQuery%' THEN 0 + WHEN gs.title LIKE '%~ $searchQuery%' THEN 1 + ELSE 2 + END AS SecondarySort + FROM game_sets AS gs + WHERE gs.deleted_at IS NULL + AND gs.type = '" . GameSetType::Hub->value . "' + AND gs.title LIKE '%$searchQuery%' + GROUP BY gs.id, gs.title + ORDER BY SecondarySort, REPLACE(gs.title, '|', ''), gs.title"; + } + if (in_array(SearchType::Achievement, $searchType)) { $counts[] = "SELECT COUNT(*) AS Count FROM Achievements WHERE Title LIKE '%$searchQuery%'"; $parts[] = " diff --git a/app/Http/Actions/UpdateDiscordNicknameAction.php b/app/Http/Actions/UpdateDiscordNicknameAction.php new file mode 100644 index 0000000000..8288ac1471 --- /dev/null +++ b/app/Http/Actions/UpdateDiscordNicknameAction.php @@ -0,0 +1,79 @@ +client = new Client(); + $this->botToken = config('services.discord.rabot_token'); + $this->guildId = config('services.discord.guild_id'); + } + + public function execute(string $oldUsername, string $newUsername): void + { + if (!$this->botToken || !$this->guildId) { + return; + } + + try { + $member = $this->findMemberByNickname($oldUsername); + if (!$member) { + return; + } + + $this->updateUserNickname($member['user']['id'], $newUsername); + } catch (Throwable $e) { + Log::error("Failed to update Discord nickname: " . $e->getMessage()); + } + } + + private function findMemberByNickname(string $nickname): ?array + { + $response = $this->client->get( + "https://discord.com/api/v10/guilds/{$this->guildId}/members/search", + [ + 'headers' => [ + 'Authorization' => "Bot {$this->botToken}", + ], + 'query' => ['query' => $nickname], + ] + ); + + $members = json_decode($response->getBody()->getContents(), true); + + // Find a case-insensitive match. + foreach ($members as $member) { + $memberNick = $member['nick'] ?? $member['user']['username']; + if (strcasecmp($memberNick, $nickname) === 0) { + return $member; + } + } + + return null; + } + + private function updateUserNickname(string $userId, string $newNickname): void + { + $this->client->patch( + "https://discord.com/api/v10/guilds/{$this->guildId}/members/{$userId}", + [ + 'headers' => [ + 'Authorization' => "Bot {$this->botToken}", + ], + 'json' => ['nick' => $newNickname], + ] + ); + } +} diff --git a/app/Models/UserUsername.php b/app/Models/UserUsername.php index c68a84320b..6546a131a9 100644 --- a/app/Models/UserUsername.php +++ b/app/Models/UserUsername.php @@ -45,6 +45,27 @@ public function getIsExpiredAttribute(): bool return $this->created_at->isPast() && $this->created_at->diffInDays(now()) >= 30; } + public function getPreviousUsagesAttribute(): array + { + $usages = []; + + $previousApprovedChanges = static::query() + ->with('user') + ->where('username', $this->username) + ->where('id', '!=', $this->id) + ->whereNotNull('approved_at') + ->get(); + + foreach ($previousApprovedChanges as $change) { + $usages[] = [ + 'user' => $change->user, + 'when' => $change->approved_at, + ]; + } + + return $usages; + } + // == mutators // == relations diff --git a/app/Policies/MessagePolicy.php b/app/Policies/MessagePolicy.php index be4898b60c..0bda817450 100644 --- a/app/Policies/MessagePolicy.php +++ b/app/Policies/MessagePolicy.php @@ -68,7 +68,6 @@ public function sendToRecipient(User $user, User $targetUser): bool */ $canUserAlwaysPierceNoContactPreference = $user->hasAnyRole([ Role::ADMINISTRATOR, - Role::DEVELOPER_JUNIOR, Role::DEVELOPER, Role::EVENT_MANAGER, Role::FORUM_MANAGER, diff --git a/config/services.php b/config/services.php index e63196f19f..e0979fa276 100755 --- a/config/services.php +++ b/config/services.php @@ -18,6 +18,8 @@ 'client_id' => env('DISCORD_KEY'), 'client_secret' => env('DISCORD_SECRET'), 'invite_id' => env('DISCORD_INVITE_ID'), + 'guild_id' => env('DISCORD_GUILD_ID'), + 'rabot_token' => env('DISCORD_RABOT_TOKEN'), 'webhook' => [ // public 'achievements' => env('DISCORD_WEBHOOK_ACHIEVEMENTS'), diff --git a/public/dorequest.php b/public/dorequest.php index 996f24a1c0..4a9279856d 100644 --- a/public/dorequest.php +++ b/public/dorequest.php @@ -1,5 +1,6 @@ first(); + $foundDelegateToUser = (new FindUserByIdentifierAction())->execute($delegateTo); if (!$foundDelegateToUser) { return DoRequestError("The target user couldn't be found.", 404, 'not_found'); } @@ -177,7 +178,7 @@ function DoRequestError(string $error, ?int $status = 200, ?string $code = null) // Replace the initiating user's properties with those of the user being delegated. $user = $foundDelegateToUser; - $username = $delegateTo; + $username = $foundDelegateToUser->username; } switch ($requestType) { diff --git a/public/request/search.php b/public/request/search.php index af2a7ac824..11eae34d32 100644 --- a/public/request/search.php +++ b/public/request/search.php @@ -25,7 +25,7 @@ } elseif ($source == 'user' || $source == 'game-compare') { $order = [SearchType::User]; } else { - $order = [SearchType::Game, SearchType::Achievement, SearchType::User]; + $order = [SearchType::Game, SearchType::Hub, SearchType::Achievement, SearchType::User]; } performSearch($order, $searchTerm, 0, $maxResults, $permissions, $results, wantTotalResults: false); diff --git a/resources/js/common/components/+vendor/BaseSelect.tsx b/resources/js/common/components/+vendor/BaseSelect.tsx index 92651bd41a..6bfba521c6 100644 --- a/resources/js/common/components/+vendor/BaseSelect.tsx +++ b/resources/js/common/components/+vendor/BaseSelect.tsx @@ -46,13 +46,16 @@ const BaseSelectContent = React.forwardRef< [] { + const { auth } = usePageProps(); + const { t } = useTranslation(); const columnDefinitions = useMemo(() => { @@ -42,9 +46,12 @@ export function useColumnDefinitions(options: { columns.push(buildNumUnresolvedTicketsColumnDef({ t_label: t('Tickets') })); } + if (auth?.user) { + columns.push(buildPlayerGameProgressColumnDef({ t_label: t('Progress') })); + } + columns.push( ...([ - buildPlayerGameProgressColumnDef({ t_label: t('Progress') }), buildHasActiveOrInReviewClaimsColumnDef({ t_label: t('Claimed'), strings: { @@ -57,7 +64,7 @@ export function useColumnDefinitions(options: { ); return columns; - }, [options.canSeeOpenTicketsColumn, options.forUsername, t]); + }, [auth?.user, options.canSeeOpenTicketsColumn, options.forUsername, t]); return columnDefinitions; } diff --git a/resources/js/features/game-list/components/DataTablePagination/ManualPaginatorField.tsx b/resources/js/features/game-list/components/DataTablePagination/ManualPaginatorField.tsx index c968ba64f8..760dd72e14 100644 --- a/resources/js/features/game-list/components/DataTablePagination/ManualPaginatorField.tsx +++ b/resources/js/features/game-list/components/DataTablePagination/ManualPaginatorField.tsx @@ -5,6 +5,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { useDebounce } from 'react-use'; import { BaseInput } from '@/common/components/+vendor/BaseInput'; +import { cn } from '@/common/utils/cn'; interface ManualPaginatorFieldProps { table: Table; @@ -60,7 +61,12 @@ export function ManualPaginatorField({ type="number" min={1} max={totalPages} - className="h-8 max-w-[80px] pt-[5px] text-[13px] text-neutral-200 light:text-neutral-900" + className={cn( + 'h-8 max-w-[80px] pt-[5px] text-[13px] text-neutral-200 light:text-neutral-900', + + // Hide the number spinner on desktop browsers -- it can obstruct the input field. + 'appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', + )} value={inputValue} onChange={(e: ChangeEvent) => setInputValue(e.target.value)} aria-label={t('current page number')} diff --git a/resources/js/features/game-list/components/HubGamesDataTable/useColumnDefinitions.ts b/resources/js/features/game-list/components/HubGamesDataTable/useColumnDefinitions.ts index a09ffd5895..74de99f7da 100644 --- a/resources/js/features/game-list/components/HubGamesDataTable/useColumnDefinitions.ts +++ b/resources/js/features/game-list/components/HubGamesDataTable/useColumnDefinitions.ts @@ -25,6 +25,8 @@ export function useColumnDefinitions(options: { canSeeOpenTicketsColumn: boolean; forUsername?: string; }): ColumnDef[] { + const { auth } = usePageProps(); + const { hub } = usePageProps(); const { t } = useTranslation(); @@ -82,13 +84,18 @@ export function useColumnDefinitions(options: { ); } - columns.push( - ...([ + if (auth?.user) { + columns.push( buildPlayerGameProgressColumnDef({ tableApiRouteName, tableApiRouteParams, t_label: t('Progress'), }), + ); + } + + columns.push( + ...([ buildHasActiveOrInReviewClaimsColumnDef({ tableApiRouteName, tableApiRouteParams, @@ -103,7 +110,7 @@ export function useColumnDefinitions(options: { ); return columns; - }, [hub.id, options.canSeeOpenTicketsColumn, options.forUsername, t]); + }, [auth?.user, hub.id, options.canSeeOpenTicketsColumn, options.forUsername, t]); return columnDefinitions; } diff --git a/resources/js/features/game-list/components/HubMainRoot/HubBreadcrumbs/HubBreadcrumbs.tsx b/resources/js/features/game-list/components/HubMainRoot/HubBreadcrumbs/HubBreadcrumbs.tsx index 35d19fd31f..a0366e7dd8 100644 --- a/resources/js/features/game-list/components/HubMainRoot/HubBreadcrumbs/HubBreadcrumbs.tsx +++ b/resources/js/features/game-list/components/HubMainRoot/HubBreadcrumbs/HubBreadcrumbs.tsx @@ -9,7 +9,6 @@ import { BaseBreadcrumbPage, BaseBreadcrumbSeparator, } from '@/common/components/+vendor/BaseBreadcrumb'; -import { InertiaLink } from '@/common/components/InertiaLink'; import { cleanHubTitle } from '@/common/utils/cleanHubTitle'; interface HubBreadcrumbsProps { @@ -38,10 +37,8 @@ export const HubBreadcrumbs: FC = ({ breadcrumbs }) => { {index !== breadcrumbs.length - 1 ? ( <> - - - {currentTitle} - + + {currentTitle} diff --git a/resources/js/features/game-list/components/HubMainRoot/RelatedHubs/RelatedHubs.tsx b/resources/js/features/game-list/components/HubMainRoot/RelatedHubs/RelatedHubs.tsx index 32fe1b7c63..112a3192f8 100644 --- a/resources/js/features/game-list/components/HubMainRoot/RelatedHubs/RelatedHubs.tsx +++ b/resources/js/features/game-list/components/HubMainRoot/RelatedHubs/RelatedHubs.tsx @@ -10,7 +10,6 @@ import { BaseTableRow, } from '@/common/components/+vendor/BaseTable'; import { GameTitle } from '@/common/components/GameTitle'; -import { InertiaLink } from '@/common/components/InertiaLink'; import { useFormatNumber } from '@/common/hooks/useFormatNumber'; import { usePageProps } from '@/common/hooks/usePageProps'; import { cleanHubTitle } from '@/common/utils/cleanHubTitle'; @@ -42,7 +41,7 @@ export const RelatedHubs: FC = () => { {relatedHubs.map((relatedHub) => ( - @@ -57,7 +56,7 @@ export const RelatedHubs: FC = () => { /> - + diff --git a/resources/js/features/game-list/components/SystemGamesDataTable/useColumnDefinitions.ts b/resources/js/features/game-list/components/SystemGamesDataTable/useColumnDefinitions.ts index c18180905d..9c07820c63 100644 --- a/resources/js/features/game-list/components/SystemGamesDataTable/useColumnDefinitions.ts +++ b/resources/js/features/game-list/components/SystemGamesDataTable/useColumnDefinitions.ts @@ -24,6 +24,8 @@ export function useColumnDefinitions(options: { canSeeOpenTicketsColumn: boolean; forUsername?: string; }): ColumnDef[] { + const { auth } = usePageProps(); + const { system } = usePageProps(); const { t } = useTranslation(); @@ -79,13 +81,18 @@ export function useColumnDefinitions(options: { ); } - columns.push( - ...([ + if (auth?.user) { + columns.push( buildPlayerGameProgressColumnDef({ tableApiRouteName, tableApiRouteParams, t_label: t('Progress'), }), + ); + } + + columns.push( + ...([ buildHasActiveOrInReviewClaimsColumnDef({ tableApiRouteName, tableApiRouteParams, @@ -100,7 +107,7 @@ export function useColumnDefinitions(options: { ); return columns; - }, [options.canSeeOpenTicketsColumn, options.forUsername, system.id, t]); + }, [auth?.user, options.canSeeOpenTicketsColumn, options.forUsername, system.id, t]); return columnDefinitions; } diff --git a/resources/views/pages-legacy/achievementInfo.blade.php b/resources/views/pages-legacy/achievementInfo.blade.php index 1c949ad855..233410b86e 100644 --- a/resources/views/pages-legacy/achievementInfo.blade.php +++ b/resources/views/pages-legacy/achievementInfo.blade.php @@ -47,7 +47,7 @@ $dateCreated = $dataOut['DateCreated']; $dateModified = $dataOut['DateModified']; $achMem = $dataOut['MemAddr']; -$isAuthor = $user == $author; +$isAuthor = $userModel?->display_name === $author; $canEmbedVideo = ( $permissions >= Permissions::Developer diff --git a/resources/views/pages-legacy/gameInfo.blade.php b/resources/views/pages-legacy/gameInfo.blade.php index b4e37db8ec..cbcf9a3a98 100644 --- a/resources/views/pages-legacy/gameInfo.blade.php +++ b/resources/views/pages-legacy/gameInfo.blade.php @@ -125,7 +125,7 @@ $allSimilarGames = $gameModel->similarGamesList; $allGameHubSets = $gameModel->hubs; -$gameHubs = $allGameHubSets->map($mapGameHubToAlt)->values()->all(); +$gameHubs = $allGameHubSets->map($mapGameHubToAlt)->values()->sortBy('Title')->all(); $v = requestInputSanitized('v', 0, 'integer'); $gate = false; @@ -1040,7 +1040,7 @@ function resize() { echo ""; } - $mappedSimilarGames = $allSimilarGames->map($mapGameToAlt); + $mappedSimilarGames = $allSimilarGames->sortBy('Title')->map($mapGameToAlt); $onlySimilarGameSubsets = $mappedSimilarGames ->filter(fn (array $game) => str_contains($game['Title'], '[Subset -') && $game['ConsoleName'] !== 'Events') diff --git a/resources/views/pages-legacy/globalRanking.blade.php b/resources/views/pages-legacy/globalRanking.blade.php index 64843ececa..0c42576f73 100644 --- a/resources/views/pages-legacy/globalRanking.blade.php +++ b/resources/views/pages-legacy/globalRanking.blade.php @@ -232,10 +232,15 @@ if ($dataPoint['Points'] != $rankPoints) { if ($rankPoints === null && $friends === 0 && $type === 2 && $offset > 0) { - // first rank of subsequent pages for all users / all time should be calculated - // as it might be shared with users on the previous page - $rankType = ($unlockMode == UnlockMode::Hardcore) ? RankType::Hardcore : RankType::Softcore; - $rank = getUserRank($dataPoint['User'], $rankType); + // The first rank of subsequent pages for all users / all time should be calculated, + // as it might be tied with users on the previous page. + // Values >10 indicate descending order, so we'll use modulo to get the base sort type. + $rank = match ($sort % 10) { + 5 => getUserRank($dataPoint['User'], RankType::Hardcore), + 2 => getUserRank($dataPoint['User'], RankType::Softcore), + 6 => getUserRank($dataPoint['User'], RankType::TruePoints), + default => $rowRank + }; } else { $rank = $rowRank; } diff --git a/resources/views/pages-legacy/searchresults.blade.php b/resources/views/pages-legacy/searchresults.blade.php index eedf2929c5..702fd4fe5e 100644 --- a/resources/views/pages-legacy/searchresults.blade.php +++ b/resources/views/pages-legacy/searchresults.blade.php @@ -4,6 +4,7 @@ use App\Enums\SearchType; use App\Models\Achievement; +use App\Models\GameSet; authenticateFromCookie($user, $permissions, $userDetails); @@ -112,6 +113,21 @@ echo ""; break; + case SearchType::Hub: + echo "Hub"; + $hub = GameSet::find($nextID); + echo ""; + echo gameAvatar( + [ + 'GameID' => $hub->game_id, + 'ImageIcon' => $hub->image_asset_path + ], + title: $hub->title, + href: route('hub.show', $hub) + ); + echo ""; + break; + case SearchType::Forum: echo "Forum Comment"; echo ""; diff --git a/tests/Feature/Connect/AwardAchievementTest.php b/tests/Feature/Connect/AwardAchievementTest.php index a881766a4b..6fdf5e17ef 100644 --- a/tests/Feature/Connect/AwardAchievementTest.php +++ b/tests/Feature/Connect/AwardAchievementTest.php @@ -355,7 +355,7 @@ public function testSoftcoreUnlockPromotedToHardcore(): void ); } - public function testDelegatedUnlock(): void + public function testDelegatedUnlockByName(): void { $now = Carbon::now()->clone()->subMinutes(5)->startOfSecond(); Carbon::setTestNow($now); @@ -536,6 +536,114 @@ public function testDelegatedUnlock(): void ]); } + public function testDelegatedUnlockByUlid(): void + { + $now = Carbon::now()->clone()->subMinutes(5)->startOfSecond(); + Carbon::setTestNow($now); + + /** @var System $standalonesSystem */ + $standalonesSystem = System::factory()->create(['ID' => 102]); + /** @var Game $gameOne */ + $gameOne = $this->seedGame(system: $standalonesSystem, withHash: false); + + /** @var User $integrationUser */ + $integrationUser = User::factory()->create(['Permissions' => Permissions::Registered, 'appToken' => Str::random(16)]); + /** @var User $delegatedUser */ + $delegatedUser = User::factory()->create(['User' => 'Username', 'Permissions' => Permissions::Registered, 'appToken' => Str::random(16)]); + + $delegatedUser->LastGameID = $gameOne->id; + $delegatedUser->save(); + + /** @var Achievement $achievement1 */ + $achievement1 = Achievement::factory()->published()->create(['ID' => 1, 'GameID' => $gameOne->ID, 'user_id' => $integrationUser->id]); + /** @var Achievement $achievement2 */ + $achievement2 = Achievement::factory()->published()->create(['ID' => 2, 'GameID' => $gameOne->ID, 'user_id' => $integrationUser->id]); + /** @var Achievement $achievement3 */ + $achievement3 = Achievement::factory()->published()->create(['ID' => 3, 'GameID' => $gameOne->ID, 'user_id' => $integrationUser->id]); + /** @var Achievement $achievement4 */ + $achievement4 = Achievement::factory()->published()->create(['ID' => 4, 'GameID' => $gameOne->ID, 'user_id' => 9999999]); + /** @var Achievement $achievement5 */ + $achievement5 = Achievement::factory()->published()->create(['ID' => 5, 'GameID' => $gameOne->ID, 'user_id' => $integrationUser->id]); + /** @var Achievement $achievement6 */ + $achievement6 = Achievement::factory()->published()->create(['ID' => 6, 'GameID' => $gameOne->ID, 'user_id' => $integrationUser->id]); + + $unlock1Date = $now->clone()->subMinutes(65); + $this->addHardcoreUnlock($delegatedUser, $achievement1, $unlock1Date); + $this->addHardcoreUnlock($delegatedUser, $achievement5, $unlock1Date); + $this->addHardcoreUnlock($delegatedUser, $achievement6, $unlock1Date); + + $playerSession1 = PlayerSession::where([ + 'user_id' => $delegatedUser->id, + 'game_id' => $achievement3->game_id, + ])->orderByDesc('id')->first(); + $this->assertModelExists($playerSession1); + + // cache the unlocks for the game - verify singular unlock captured + $unlocks = getUserAchievementUnlocksForGame($delegatedUser->User, $gameOne->ID); + $this->assertEquals([$achievement1->ID, $achievement5->ID, $achievement6->ID], array_keys($unlocks)); + + // do the delegated hardcore unlock + $scoreBefore = $delegatedUser->RAPoints; + $softcoreScoreBefore = $delegatedUser->RASoftcorePoints; + + $params = [ + 'u' => $integrationUser->User, + 't' => $integrationUser->appToken, + 'r' => 'awardachievement', + 'k' => $delegatedUser->ulid, // !! + 'h' => 1, + 'a' => $achievement3->ID, + 'v' => '62c47b9fba313855ff8a09673780bb35', + ]; + + $requestUrl = sprintf('dorequest.php?%s', http_build_query($params)); + $this->post($requestUrl) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement3->ID, + 'AchievementsRemaining' => 2, + 'Score' => $scoreBefore + $achievement3->Points, + 'SoftcoreScore' => $softcoreScoreBefore, + ]); + $delegatedUser->refresh(); + + // player session resumed + $playerSession2 = PlayerSession::where([ + 'user_id' => $delegatedUser->id, + 'game_id' => $achievement3->game_id, + ])->orderByDesc('id')->first(); + $this->assertModelExists($playerSession2); + + // game attached + $playerGame = PlayerGame::where([ + 'user_id' => $delegatedUser->id, + 'game_id' => $achievement3->game_id, + ])->first(); + $this->assertModelExists($playerGame); + $this->assertNotNull($playerGame->last_played_at); + + // achievement unlocked + $playerAchievement = PlayerAchievement::where([ + 'user_id' => $delegatedUser->id, + 'achievement_id' => $achievement3->id, + ])->first(); + $this->assertModelExists($playerAchievement); + $this->assertNotNull($playerAchievement->unlocked_at); + $this->assertNotNull($playerAchievement->unlocked_hardcore_at); + $this->assertEquals($playerAchievement->player_session_id, $playerSession2->id); + + // player score should have increased + $user1 = User::whereName($delegatedUser->User)->first(); + $this->assertEquals($scoreBefore + $achievement3->Points, $user1->RAPoints); + $this->assertEquals($softcoreScoreBefore, $user1->RASoftcorePoints); + + // make sure the unlock cache was updated + $unlocks = getUserAchievementUnlocksForGame($delegatedUser->User, $gameOne->ID); + $this->assertEqualsCanonicalizing([$achievement1->ID, $achievement5->ID, $achievement6->ID, $achievement3->ID], array_keys($unlocks)); + $this->assertEquals($now, $unlocks[$achievement3->ID]['DateEarnedHardcore']); + $this->assertEquals($now, $unlocks[$achievement3->ID]['DateEarned']); + } + public function testBackdatedUnlock(): void { $now = Carbon::now()->clone()->subMinutes(5)->startOfSecond(); diff --git a/tests/Feature/Connect/PingTest.php b/tests/Feature/Connect/PingTest.php index fae65b7d60..f17f8b41e3 100644 --- a/tests/Feature/Connect/PingTest.php +++ b/tests/Feature/Connect/PingTest.php @@ -245,7 +245,7 @@ public function testPingUserTokenMismatch(): void ]); } - public function testPingDelegated(): void + public function testPingDelegatedByName(): void { /** @var System $standalonesSystem */ $standalonesSystem = System::factory()->create(['ID' => 102]); @@ -272,7 +272,94 @@ public function testPingDelegated(): void 'r' => 'ping', 'g' => $gameOne->id, 'm' => 'Doing good', - 'k' => $delegatedUser->User, + 'k' => $delegatedUser->User, // !! + ]; + + $this->post('dorequest.php', $params) + ->assertStatus(200) + ->assertExactJson([ + 'Success' => true, + ]); + + $delegatedPlayerSession = PlayerSession::where([ + 'user_id' => $delegatedUser->id, + 'game_id' => $gameOne->id, + ])->first(); + $this->assertModelExists($delegatedPlayerSession); + $this->assertEquals(1, $delegatedPlayerSession->duration); + $this->assertEquals('Doing good', $delegatedPlayerSession->rich_presence); + + // While delegating, updates are made on behalf of username `k`. + $this->assertDatabaseMissing((new PlayerSession())->getTable(), [ + 'user_id' => $integrationUser->id, + 'game_id' => $gameOne->id, + ]); + + // Next, try to delegate on a non-standalone game. + // This is not allowed and should fail. + /** @var System $normalSystem */ + $normalSystem = System::factory()->create(['ID' => 1]); + /** @var Game $gameTwo */ + $gameTwo = Game::factory()->create(['ConsoleID' => $normalSystem->ID]); + + $params['g'] = $gameTwo->id; + + $this->post('dorequest.php', $params) + ->assertStatus(403) + ->assertExactJson([ + "Success" => false, + "Error" => "You do not have permission to do that.", + "Code" => "access_denied", + "Status" => 403, + ]); + + // Next, try to delegate on a game with no achievements authored by the integration user. + // This is not allowed and should fail. + /** @var Game $gameThree */ + $gameThree = Game::factory()->create(['ConsoleID' => $standalonesSystem->ID]); + /** @var User $randomUser */ + $randomUser = User::factory()->create(['Permissions' => Permissions::Registered, 'appToken' => Str::random(16)]); + Achievement::factory()->published()->count(6)->create(['GameID' => $gameThree->id, 'user_id' => $randomUser->id]); + $params['g'] = $gameThree->id; + + $this->post('dorequest.php', $params) + ->assertStatus(403) + ->assertExactJson([ + "Success" => false, + "Error" => "You do not have permission to do that.", + "Code" => "access_denied", + "Status" => 403, + ]); + } + + public function testPingDelegatedByUlid(): void + { + /** @var System $standalonesSystem */ + $standalonesSystem = System::factory()->create(['ID' => 102]); + /** @var Game $gameOne */ + $gameOne = Game::factory()->create(['ConsoleID' => $standalonesSystem->ID]); + + /** @var User $integrationUser */ + $integrationUser = User::factory()->create(['Permissions' => Permissions::Registered, 'appToken' => Str::random(16)]); + /** @var User $delegatedUser */ + $delegatedUser = User::factory()->create(['Permissions' => Permissions::Registered, 'appToken' => Str::random(16)]); + + $delegatedUser->LastGameID = $gameOne->id; + $delegatedUser->save(); + + // The integration user is the sole author of all the set's achievements. + Achievement::factory()->published()->count(6)->create([ + 'GameID' => $gameOne->id, + 'user_id' => $integrationUser->id, + ]); + + $params = [ + 'u' => $integrationUser->User, + 't' => $integrationUser->appToken, + 'r' => 'ping', + 'g' => $gameOne->id, + 'm' => 'Doing good', + 'k' => $delegatedUser->ulid, // !! ]; $this->post('dorequest.php', $params) diff --git a/tests/Feature/Connect/StartSessionTest.php b/tests/Feature/Connect/StartSessionTest.php index 90dd577777..1e7bc6e703 100644 --- a/tests/Feature/Connect/StartSessionTest.php +++ b/tests/Feature/Connect/StartSessionTest.php @@ -631,7 +631,7 @@ public function testStartSession(): void $this->assertEquals($this->userAgentUnknown, $playerSession5->user_agent); } - public function testStartSessionDelegated(): void + public function testStartSessionDelegatedByName(): void { $now = Carbon::create(2020, 3, 4, 16, 40, 13); // 4:40:13pm 4 Mar 2020 Carbon::setTestNow($now); @@ -797,4 +797,115 @@ public function testStartSessionDelegated(): void "Status" => 405, ]); } + + public function testStartSessionDelegatedByUlid(): void + { + $now = Carbon::create(2020, 3, 4, 16, 40, 13); // 4:40:13pm 4 Mar 2020 + Carbon::setTestNow($now); + + /** @var System $standalonesSystem */ + $standalonesSystem = System::factory()->create(['ID' => 102]); + /** @var Game $gameOne */ + $gameOne = Game::factory()->create(['ConsoleID' => $standalonesSystem->ID]); + + /** @var User $integrationUser */ + $integrationUser = User::factory()->create(['Permissions' => Permissions::Registered, 'appToken' => Str::random(16)]); + /** @var User $delegatedUser */ + $delegatedUser = User::factory()->create(['Permissions' => Permissions::Registered, 'appToken' => Str::random(16)]); + + // The integration user is the sole author of all the set's achievements. + $coreAchievements = Achievement::factory()->published()->count(3)->create([ + 'GameID' => $gameOne->id, + 'user_id' => $integrationUser->id, + ]); + $this->upsertGameCoreSetAction->execute($gameOne); + + /** @var Game $bonusGameOne */ + $bonusGameOne = Game::factory()->create([ + 'ConsoleID' => $standalonesSystem->ID, + 'Title' => $gameOne->Title . ' [Subset - Bonus]', + ]); + $bonusAchievements = Achievement::factory()->published()->count(3)->create([ + 'GameID' => $bonusGameOne->id, + 'user_id' => $integrationUser->id, + ]); + $this->upsertGameCoreSetAction->execute($bonusGameOne); + $this->associateAchievementSetToGameAction->execute($gameOne, $bonusGameOne, AchievementSetType::Bonus, 'Bonus'); + + // ... core unlocks ... + $unlock1Date = $now->clone()->subMinutes(65); + $this->addHardcoreUnlock($delegatedUser, $coreAchievements->get(0), $unlock1Date); + $unlock2Date = $now->clone()->subMinutes(22); + $this->addHardcoreUnlock($delegatedUser, $coreAchievements->get(1), $unlock2Date); + $unlock3Date = $now->clone()->subMinutes(1); + $this->addSoftcoreUnlock($delegatedUser, $coreAchievements->get(2), $unlock3Date); + + // ... bonus unlocks ... + $bonusUnlock1Date = $now->clone()->subMinutes(45); + $this->addHardcoreUnlock($delegatedUser, $bonusAchievements->get(0), $bonusUnlock1Date); + $bonusUnlock2Date = $now->clone()->subMinutes(15); + $this->addSoftcoreUnlock($delegatedUser, $bonusAchievements->get(1), $bonusUnlock2Date); + + $this->seedEmulatorUserAgents(); + + $params = [ + 'u' => $integrationUser->User, + 't' => $integrationUser->appToken, + 'r' => 'startsession', + 'g' => $gameOne->id, + 'k' => $delegatedUser->ulid, // !! + ]; + + // ---------------------------- + // game with unlocks + $requestUrl = sprintf('dorequest.php?%s', http_build_query($params)); + $this->withHeaders(['User-Agent' => $this->userAgentValid]) + ->post($requestUrl) + ->assertExactJson([ + 'Success' => true, + 'HardcoreUnlocks' => [ + [ + 'ID' => $coreAchievements->get(0)->ID, + 'When' => $unlock1Date->timestamp, + ], + [ + 'ID' => $coreAchievements->get(1)->ID, + 'When' => $unlock2Date->timestamp, + ], + [ + 'ID' => $bonusAchievements->get(0)->ID, + 'When' => $bonusUnlock1Date->timestamp, + ], + ], + 'Unlocks' => [ + [ + 'ID' => $coreAchievements->get(2)->ID, + 'When' => $unlock3Date->timestamp, + ], + [ + 'ID' => $bonusAchievements->get(1)->ID, + 'When' => $bonusUnlock2Date->timestamp, + ], + ], + 'ServerNow' => Carbon::now()->timestamp, + ]); + + // player session created + $playerSession = PlayerSession::where([ + 'user_id' => $delegatedUser->id, + 'game_id' => $bonusGameOne->id, + ])->first(); + $this->assertModelExists($playerSession); + $this->assertEquals(1, $playerSession->duration); + $this->assertEquals('Playing ' . $bonusGameOne->title, $playerSession->rich_presence); + + $this->assertEquals($bonusGameOne->id, $delegatedUser->LastGameID); + $this->assertEquals("Playing " . $bonusGameOne->Title, $delegatedUser->RichPresenceMsg); + + // While delegating, updates are made on behalf of username `k`. + $this->assertDatabaseMissing((new PlayerSession())->getTable(), [ + 'user_id' => $integrationUser->id, + 'game_id' => $bonusGameOne->id, + ]); + } }