From ea54e15890da300b8e62c21d8b54e0fb4acd0213 Mon Sep 17 00:00:00 2001 From: luchaos Date: Fri, 27 Oct 2023 02:34:24 +0200 Subject: [PATCH 1/6] feat: switch to mariadb and convert to utf8mb4 (#1930) --- .env.example | 3 +++ .gitignore | 1 + app/Site/AppServiceProvider.php | 2 +- config/database.php | 7 ++----- docker-compose.yml | 27 +++++++++++++++---------- docker/mysql/Dockerfile | 5 ++--- docker/mysql/create-testing-database.sh | 6 ------ docker/mysql/mysql.cnf | 9 +++++++++ 8 files changed, 34 insertions(+), 26 deletions(-) delete mode 100644 docker/mysql/create-testing-database.sh diff --git a/.env.example b/.env.example index 8f7f9d4527..b5cda929a5 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,9 @@ DB_PORT=3306 DB_DATABASE=retroachievements-web DB_USERNAME=retroachievements DB_PASSWORD="${DB_USERNAME}" +# TODO remove after utf8mb4 conversion +#DB_CHARSET=latin1 +#DB_COLLATION=latin1_general_ci #LEGACY_MEDIA_PATH= diff --git a/.gitignore b/.gitignore index 8830082a41..c6e841d873 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/database/*.sql /docker/nginx/logs /docs/dist /node_modules diff --git a/app/Site/AppServiceProvider.php b/app/Site/AppServiceProvider.php index d5e93beafa..f91b7687a5 100644 --- a/app/Site/AppServiceProvider.php +++ b/app/Site/AppServiceProvider.php @@ -114,7 +114,7 @@ public function boot(): void if (!$db) { throw new Exception('Could not connect to database. Please try again later.'); } - mysqli_set_charset($db, 'latin1'); + mysqli_set_charset($db, config('database.connections.mysql.charset')); mysqli_query($db, "SET sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"); return $db; diff --git a/config/database.php b/config/database.php index 96f2650258..e5264525f2 100755 --- a/config/database.php +++ b/config/database.php @@ -54,11 +54,8 @@ 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), - // TODO - // 'charset' => 'utf8mb4', - // 'collation' => 'utf8mb4_general_ci', - 'charset' => 'latin1', - 'collation' => 'latin1_general_ci', + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), 'prefix' => '', 'prefix_indexes' => false, 'strict' => true, diff --git a/docker-compose.yml b/docker-compose.yml index 606b6d8385..ab1fe5c21c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: networks: - raweb depends_on: - - mysql + - mariadb - redis - minio nginx: @@ -38,31 +38,36 @@ services: - raweb depends_on: - laravel.test - mysql: + mariadb: build: context: ./docker/mysql dockerfile: Dockerfile - image: 'mysql-pv:8' + image: 'mariadb-pv:10' environment: + MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' + MYSQL_ROOT_HOST: "%" MYSQL_DATABASE: '${DB_DATABASE}' MYSQL_USER: '${DB_USERNAME}' - MYSQL_PASSWORD: '${DB_PASSWORD:-secret}' - MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-secret}' + MYSQL_PASSWORD: '${DB_PASSWORD}' + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' ports: - '${FORWARD_DB_PORT:-3306}:3306' volumes: - - 'mysql-data:/var/lib/mysql' - - './database:/docker-entrypoint-initdb.d/' + - 'mariadb-data:/var/lib/mysql' + - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh' + - './database:/docker-entrypoint-initdb.d/database' - './docker/mysql/mysql.cnf:/etc/mysql/conf.d/mysql.cnf:ro' networks: - raweb - command: - - '--default-authentication-plugin=mysql_native_password' + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-p${DB_PASSWORD}"] + retries: 3 + timeout: 5s phpmyadmin: image: phpmyadmin/phpmyadmin environment: PMA_ARBITRARY: 1 - PMA_HOST: mysql + PMA_HOST: mariadb PMA_USER: '${DB_USERNAME}' PMA_PASSWORD: '${DB_PASSWORD}' PMA_PORT: 3306 @@ -117,7 +122,7 @@ networks: raweb: driver: bridge volumes: - mysql-data: + mariadb-data: driver: local minio-data: driver: local diff --git a/docker/mysql/Dockerfile b/docker/mysql/Dockerfile index 9502d74469..27d8036340 100644 --- a/docker/mysql/Dockerfile +++ b/docker/mysql/Dockerfile @@ -1,4 +1,3 @@ -FROM mysql:8 +FROM mariadb:10 -RUN microdnf install -y epel-release -RUN microdnf install -y pv +RUN apt-get update && apt-get install -y pv diff --git a/docker/mysql/create-testing-database.sh b/docker/mysql/create-testing-database.sh deleted file mode 100644 index aeb1826f1e..0000000000 --- a/docker/mysql/create-testing-database.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -mysql --user=root --password="$MYSQL_ROOT_PASSWORD" <<-EOSQL - CREATE DATABASE IF NOT EXISTS testing; - GRANT ALL PRIVILEGES ON \`testing%\`.* TO '$MYSQL_USER'@'%'; -EOSQL diff --git a/docker/mysql/mysql.cnf b/docker/mysql/mysql.cnf index 4f1829d66a..d126fd7790 100644 --- a/docker/mysql/mysql.cnf +++ b/docker/mysql/mysql.cnf @@ -1,3 +1,12 @@ +[client] +default-character-set = utf8mb4 + +[mysql] +default-character-set = utf8mb4 + [mysqld] skip-host-cache skip-name-resolve + +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci From f271b80e6190183bece63b50e299cff944c6e01e Mon Sep 17 00:00:00 2001 From: Jamiras <32680403+Jamiras@users.noreply.github.com> Date: Fri, 27 Oct 2023 08:52:27 -0600 Subject: [PATCH 2/6] Cache ticket count (#1928) --- app/Helpers/database/set-claim.php | 30 +++++++++---- app/Helpers/database/ticket.php | 67 ++++++++++++++++++++---------- app/Support/Cache/CacheKey.php | 15 +++++++ 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/app/Helpers/database/set-claim.php b/app/Helpers/database/set-claim.php index 648116a576..f02e56321b 100644 --- a/app/Helpers/database/set-claim.php +++ b/app/Helpers/database/set-claim.php @@ -8,6 +8,7 @@ use App\Community\Enums\ClaimType; use App\Community\Models\AchievementSetClaim; use App\Site\Enums\Permissions; +use App\Support\Cache\CacheKey; use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -468,23 +469,38 @@ function getExpiringClaim(string $username): array return []; } + $cacheKey = CacheKey::buildUserExpiringClaimsCacheKey($username); + + $value = Cache::get($cacheKey); + if ($value !== null) { + return $value; + } + $claims = AchievementSetClaim::select( DB::raw('COALESCE(SUM(CASE WHEN TIMESTAMPDIFF(MINUTE, NOW(), Finished) <= 0 THEN 1 ELSE 0 END), 0) AS Expired'), - DB::raw('COALESCE(SUM(CASE WHEN TIMESTAMPDIFF(MINUTE, NOW(), Finished) BETWEEN 0 AND 10080 THEN 1 ELSE 0 END), 0) AS Expiring') + DB::raw('COALESCE(SUM(CASE WHEN TIMESTAMPDIFF(MINUTE, NOW(), Finished) BETWEEN 0 AND 10080 THEN 1 ELSE 0 END), 0) AS Expiring'), + DB::raw('COUNT(*) AS Count') ) ->where('User', $username) ->whereIn('Status', [ClaimStatus::Active, ClaimStatus::InReview]) ->where('Special', '!=', ClaimSpecial::ScheduledRelease) ->first(); - if (!$claims) { - return []; + if (!$claims || $claims['Count'] == 0) { + $value = []; + // new claim expiration is 30 days and expiration warning is 7 days, so this guarantees a refresh before expiration + Cache::put($cacheKey, $value, Carbon::now()->addDays(20)); + } else { + $value = [ + 'Expired' => $claims->Expired, + 'Expiring' => $claims->Expiring, + ]; + // refresh once an hour. this query only takes about 2ms, so it's not super expensive, but + // we want to avoid doing it on every page load. + Cache::put($cacheKey, $value, Carbon::now()->addHours(1)); } - return [ - 'Expired' => $claims->Expired, - 'Expiring' => $claims->Expiring, - ]; + return $value; } /** diff --git a/app/Helpers/database/ticket.php b/app/Helpers/database/ticket.php index 9a3424f34c..6bf4d621dc 100644 --- a/app/Helpers/database/ticket.php +++ b/app/Helpers/database/ticket.php @@ -152,6 +152,8 @@ function _createTicket(User $user, int $achID, int $reportType, ?int $hardcore, $gameID = $achData['GameID']; $gameTitle = $achData['GameTitle']; + expireUserTicketCounts($achAuthor); + $problemTypeStr = ($reportType === 1) ? "Triggers at wrong time" : "Doesn't trigger"; $bugReportDetails = "Achievement: [ach=$achID] @@ -166,7 +168,7 @@ function _createTicket(User $user, int $achID, int $reportType, ?int $hardcore, $bugReportMessage = "Hi, $achAuthor!\r\n [user=$username] would like to report a bug with an achievement you've created: $bugReportDetails"; - CreateNewMessage($username, $achData['Author'], "Bug Report ($gameTitle)", $bugReportMessage); + CreateNewMessage($username, $achAuthor, "Bug Report ($gameTitle)", $bugReportMessage); postActivity($username, ActivityType::OpenedTicket, $achID); // notify subscribers other than the achievement's author @@ -384,11 +386,15 @@ function updateTicket(string $user, int $ticketID, int $ticketVal, ?string $reas addArticleComment("Server", ArticleType::AchievementTicket, $ticketID, $comment, $user); + expireUserTicketCounts($ticketData['AchievementAuthor']); + $reporterData = []; if (!getAccountDetails($userReporter, $reporterData)) { return true; } + expireUserTicketCounts($userReporter); + $email = $reporterData['EmailAddress']; $emailTitle = "Ticket status changed"; @@ -415,9 +421,13 @@ function countRequestTicketsByUser(?User $user = null): int return 0; } - return Ticket::where('ReportState', TicketState::Request) - ->where('ReportedByUserID', $user->ID) - ->count(); + $cacheKey = CacheKey::buildUserRequestTicketsCacheKey($user->User); + + return Cache::remember($cacheKey, Carbon::now()->addHours(20), function () use ($user) { + return Ticket::where('ReportState', TicketState::Request) + ->where('ReportedByUserID', $user->ID) + ->count(); + }); } function countOpenTicketsByDev(string $dev): ?array @@ -426,27 +436,40 @@ function countOpenTicketsByDev(string $dev): ?array return null; } - $retVal = [ - TicketState::Open => 0, - TicketState::Request => 0, - ]; + $cacheKey = CacheKey::buildUserOpenTicketsCacheKey($dev); - $tickets = Ticket::with('achievement') - ->whereHas('achievement', function ($query) use ($dev) { - $query - ->where('Author', $dev) - ->whereIn('Flags', [AchievementFlag::OfficialCore, AchievementFlag::Unofficial]); - }) - ->whereIn('ReportState', [TicketState::Open, TicketState::Request]) - ->select('AchievementID', 'ReportState', DB::raw('count(*) as Count')) - ->groupBy('ReportState') - ->get(); + return Cache::remember($cacheKey, Carbon::now()->addHours(20), function () use ($dev) { + $retVal = [ + TicketState::Open => 0, + TicketState::Request => 0, + ]; - foreach ($tickets as $ticket) { - $retVal[$ticket->ReportState] = $ticket->Count; - } + $tickets = Ticket::with('achievement') + ->whereHas('achievement', function ($query) use ($dev) { + $query + ->where('Author', $dev) + ->whereIn('Flags', [AchievementFlag::OfficialCore, AchievementFlag::Unofficial]); + }) + ->whereIn('ReportState', [TicketState::Open, TicketState::Request]) + ->select('AchievementID', 'ReportState', DB::raw('count(*) as Count')) + ->groupBy('ReportState') + ->get(); - return $retVal; + foreach ($tickets as $ticket) { + $retVal[$ticket->ReportState] = (int) $ticket->Count; + } + + return $retVal; + }); +} + +function expireUserTicketCounts(string $username): void +{ + $cacheKey = CacheKey::buildUserRequestTicketsCacheKey($username); + Cache::forget($cacheKey); + + $cacheKey = CacheKey::buildUserOpenTicketsCacheKey($username); + Cache::forget($cacheKey); } function countOpenTicketsByAchievement(int $achievementID): int diff --git a/app/Support/Cache/CacheKey.php b/app/Support/Cache/CacheKey.php index 758744073c..407257907f 100644 --- a/app/Support/Cache/CacheKey.php +++ b/app/Support/Cache/CacheKey.php @@ -62,6 +62,21 @@ public static function buildUserRecentGamesCacheKey(string $username): string return self::buildNormalizedUserCacheKey($username, "recent-games"); } + public static function buildUserOpenTicketsCacheKey(string $username): string + { + return self::buildNormalizedUserCacheKey($username, "open-tickets"); + } + + public static function buildUserRequestTicketsCacheKey(string $username): string + { + return self::buildNormalizedUserCacheKey($username, "request-tickets"); + } + + public static function buildUserExpiringClaimsCacheKey(string $username): string + { + return self::buildNormalizedUserCacheKey($username, "expiring-claims"); + } + /** * Constructs a normalized cache key. * From 65c4afca510f727355d1706208702475a332fe23 Mon Sep 17 00:00:00 2001 From: Jamiras <32680403+Jamiras@users.noreply.github.com> Date: Fri, 27 Oct 2023 08:55:58 -0600 Subject: [PATCH 3/6] use aggregated data for usergameactivity (#1925) --- app/Helpers/database/user-activity.php | 279 ++++++++++++------------- public/usergameactivity.php | 18 +- 2 files changed, 150 insertions(+), 147 deletions(-) diff --git a/app/Helpers/database/user-activity.php b/app/Helpers/database/user-activity.php index cc1aa941b8..7d6ed5a479 100644 --- a/app/Helpers/database/user-activity.php +++ b/app/Helpers/database/user-activity.php @@ -5,6 +5,9 @@ use App\Community\Models\Comment; use App\Community\Models\UserActivityLegacy; use App\Platform\Enums\AchievementFlag; +use App\Platform\Models\Game; +use App\Platform\Models\PlayerAchievement; +use App\Platform\Models\PlayerSession; use App\Site\Enums\Permissions; use App\Site\Models\User; use App\Support\Cache\CacheKey; @@ -440,100 +443,165 @@ function GetMostPopularTitles(int $daysRange = 7, int $offset = 0, int $count = return $data; } -function getUserGameActivity(string $user, int $gameID): array +function getUserGameActivity(string $username, int $gameID): array { - sanitize_sql_inputs($user); - - $query = "SELECT a.timestamp, a.lastupdate, a.data - FROM Activity a - WHERE a.User='$user' AND a.data=$gameID - AND a.activitytype=" . ActivityType::StartedPlaying; - $dbResult = s_mysql_query($query); - if ($dbResult === false) { - log_sql_fail(); + $user = User::firstWhere('User', $username); + if (!$user) { + return []; + } + $game = Game::firstWhere('ID', $gameID); + if (!$game) { return []; } + $achievements = []; + $unofficialAchievements = []; $sessions = []; - while ($row = mysqli_fetch_assoc($dbResult)) { - $sessions[] = [ - 'StartTime' => strtotime($row['timestamp']), - ]; - if ($row['lastupdate'] !== $row['timestamp']) { - $sessions[] = [ - 'StartTime' => strtotime($row['lastupdate']), - ]; + $playerSessions = PlayerSession::where('user_id', '=', $user->ID) + ->where('game_id', '=', $gameID) + ->get(); + foreach ($playerSessions as $playerSession) { + $session = [ + 'StartTime' => $playerSession->created_at->unix(), + 'EndTime' => $playerSession->updated_at->unix(), + 'IsGenerated' => $playerSession->created_at < Carbon::create(2023, 10, 14, 13, 16, 42), + 'Achievements' => [], + ]; + if (!empty($playerSession->rich_presence)) { + $session['RichPresence'] = $playerSession->rich_presence; + $session['RichPresenceTime'] = $playerSession->rich_presence_updated_at->unix(); } + $sessions[] = $session; } - // create a dummy placeholder session for any achievements unlocked before the first session - $sessions[] = [ - 'StartTime' => 0, - 'IsGenerated' => true, - ]; - // reverse sort by date so we can update the appropriate session when we find it usort($sessions, fn ($a, $b) => $b['StartTime'] - $a['StartTime']); - $query = "SELECT a.timestamp, a.data, a.data2, ach.Title, ach.Description, ach.Points, ach.BadgeName, ach.Flags - FROM Activity a - LEFT JOIN Achievements ach ON ach.ID = a.data - WHERE ach.GameID=$gameID AND a.User='$user' - AND a.activitytype=" . ActivityType::UnlockedAchievement; - $dbResult = s_mysql_query($query); - if ($dbResult === false) { - log_sql_fail(); + $addAchievementToSession = function (&$sessions, $playerAchievement, $when, $hardcore): void { + $createSessionAchievement = function ($playerAchievement, $when, $hardcore): array { + return [ + 'When' => $when, + 'AchievementID' => $playerAchievement->achievement_id, + 'HardcoreMode' => $hardcore, + 'Flags' => $playerAchievement->Flags, + // used by avatar function to avoid additional query + 'Title' => $playerAchievement->Title, + 'Description' => $playerAchievement->Description, + 'Points' => $playerAchievement->Points, + 'BadgeName' => $playerAchievement->BadgeName, + ]; + }; - return []; - } + $maxSessionGap = 4 * 60 * 60; // 4 hours - $achievements = []; - $unofficialAchievements = []; - while ($row = mysqli_fetch_assoc($dbResult)) { - $when = strtotime($row['timestamp']); - $achievements[$row['data']] = $when; + $possibleSession = null; + foreach ($sessions as &$session) { + if ($session['StartTime'] <= $when) { + if ($session['EndTime'] + $maxSessionGap > $when) { + $session['Achievements'][] = $createSessionAchievement($playerAchievement, $when, $hardcore); + $session['EndTime'] = $when; - if ($row['Flags'] != AchievementFlag::OfficialCore) { - $unofficialAchievements[$row['data']] = 1; + return; + } + $possibleSession = $session; + } } - foreach ($sessions as &$session) { - if ($session['StartTime'] < $when) { - $session['Achievements'][] = [ - 'When' => $when, - 'AchievementID' => $row['data'], - 'Title' => $row['Title'], - 'Description' => $row['Description'], - 'Points' => $row['Points'], - 'BadgeName' => $row['BadgeName'], - 'Flags' => $row['Flags'], - 'HardcoreMode' => $row['data2'], - ]; - break; + if ($possibleSession) { + if ($when - $possibleSession['EndTime'] < $maxSessionGap) { + $possibleSession['Achievements'][] = $createSessionAchievement($playerAchievement, $when, $hardcore); + $possibleSession['EndTime'] = $when; + + return; + } + + $index = array_search($sessions, $possibleSession); + if ($index < count($sessions)) { + $possibleSession = $sessions[$index + 1]; + if ($possibleSession['StartTime'] - $when < $maxSessionGap) { + $possibleSession['Achievements'][] = $createSessionAchievement($playerAchievement, $when, $hardcore); + $possibleSession['StartTime'] = $when; + + return; + } } } - } - // calculate the duration of each session - $totalTime = _updateUserGameSessionDurations($sessions, $achievements); + $sessions[] = [ + 'StartTime' => $when, + 'EndTime' => $when, + 'IsGenerated' => true, + 'Achievements' => [$createSessionAchievement($playerAchievement, $when, $hardcore)], + ]; + usort($sessions, fn ($a, $b) => $b['StartTime'] - $a['StartTime']); + }; + + $playerAchievements = PlayerAchievement::where('player_achievements.user_id', '=', $user->ID) + ->join('Achievements', 'player_achievements.achievement_id', '=', 'Achievements.ID') + ->where('Achievements.GameID', '=', $gameID) + ->orderBy('player_achievements.unlocked_at') + ->select(['player_achievements.*', 'Achievements.Flags', 'Achievements.Title', + 'Achievements.Description', 'Achievements.Points', 'Achievements.BadgeName']) + ->get(); + foreach ($playerAchievements as $playerAchievement) { + if ($playerAchievement->Flags != AchievementFlag::OfficialCore) { + $unofficialAchievements[$playerAchievement->achievement_id] = 1; + } + + $achievements[$playerAchievement->achievement_id] = $playerAchievement->unlocked_at->unix(); + + if ($playerAchievement->unlocked_hardcore_at) { + $addAchievementToSession($sessions, $playerAchievement, $playerAchievement->unlocked_hardcore_at->unix(), true); + + if ($playerAchievement->unlocked_hardcore_at != $playerAchievement->unlocked_at) { + $addAchievementToSession($sessions, $playerAchievement, $playerAchievement->unlocked_at->unix(), false); + } + } else { + $addAchievementToSession($sessions, $playerAchievement, $playerAchievement->unlocked_at->unix(), false); + } + } // sort everything and find the first and last achievement timestamps usort($sessions, fn ($a, $b) => $a['StartTime'] - $b['StartTime']); + $hasGenerated = false; + $totalTime = 0; + $achievementsTime = 0; + $intermediateTime = 0; $unlockSessionCount = 0; + $intermediateSessionCount = 0; $firstAchievementTime = null; $lastAchievementTime = null; foreach ($sessions as &$session) { + $elapsed = ($session['EndTime'] - $session['StartTime']); + $totalTime += $elapsed; + if (!empty($session['Achievements'])) { + if ($achievementsTime > 0) { + $achievementsTime += $intermediateTime; + $unlockSessionCount += $intermediateSessionCount; + } + $achievementsTime += $elapsed; + $intermediateTime = 0; + $intermediateSessionCount = 0; + $unlockSessionCount++; + usort($session['Achievements'], fn ($a, $b) => $a['When'] - $b['When']); foreach ($session['Achievements'] as &$achievement) { if ($firstAchievementTime === null) { $firstAchievementTime = $achievement['When']; } $lastAchievementTime = $achievement['When']; } + + if ($session['IsGenerated']) { + $hasGenerated = true; + } + } else { + $intermediateTime += $elapsed; + $intermediateSessionCount++; } } @@ -542,9 +610,12 @@ function getUserGameActivity(string $user, int $gameID): array // approximate time per achievement earned. add this value to each session to account // for time played after getting the last achievement of the session. $achievementsUnlocked = count($achievements); - if ($achievementsUnlocked > 0 && $unlockSessionCount > 1) { - $sessionAdjustment = $totalTime / $achievementsUnlocked; - $totalTime += $sessionAdjustment * $unlockSessionCount; + if ($hasGenerated && $achievementsUnlocked > 0) { + $sessionAdjustment = $achievementsTime / $achievementsUnlocked; + $totalTime += $sessionAdjustment * count($sessions); + if ($unlockSessionCount > 1) { + $achievementsTime += $sessionAdjustment * $unlockSessionCount; + } } else { $sessionAdjustment = 0; } @@ -552,99 +623,15 @@ function getUserGameActivity(string $user, int $gameID): array $activity = [ 'Sessions' => $sessions, 'TotalTime' => $totalTime, + 'AchievementsTime' => $achievementsTime, 'PerSessionAdjustment' => $sessionAdjustment, 'AchievementsUnlocked' => count($achievements) - count($unofficialAchievements), 'UnlockSessionCount' => $unlockSessionCount, 'FirstUnlockTime' => $firstAchievementTime, 'LastUnlockTime' => $lastAchievementTime, 'TotalUnlockTime' => ($lastAchievementTime != null) ? $lastAchievementTime - $firstAchievementTime : 0, + 'CoreAchievementCount' => $game->achievements_published, ]; - // Count num possible achievements - $query = "SELECT COUNT(*) as Count FROM Achievements ach - WHERE ach.Flags=" . AchievementFlag::OfficialCore . " AND ach.GameID=$gameID"; - $dbResult = s_mysql_query($query); - if ($dbResult) { - $activity['CoreAchievementCount'] = mysqli_fetch_assoc($dbResult)['Count']; - } - return $activity; } - -function _updateUserGameSessionDurations(array &$sessions, array $achievements): int -{ - $totalTime = 0; - $newSessions = []; - foreach ($sessions as &$session) { - if (!array_key_exists('Achievements', $session)) { - if ($session['StartTime'] > 0) { - $session['Achievements'] = []; - $session['EndTime'] = $session['StartTime']; - $newSessions[] = $session; - } - } else { - usort($session['Achievements'], fn ($a, $b) => $a['When'] - $b['When']); - - if ($session['StartTime'] === 0) { - $session['StartTime'] = $session['Achievements'][0]['When']; - } - - foreach ($session['Achievements'] as &$achievement) { - if ($achievement['When'] != $achievements[$achievement['AchievementID']]) { - $achievement['UnlockedLater'] = true; - } - } - - // if there are any gaps in the achievements earned within a session that - // are more than four hours apart, split into separate sessions - $split = []; - $prevTime = $session['StartTime']; - $itemsCount = count($session['Achievements']); - for ($i = 0; $i < $itemsCount; $i++) { - $distance = $session['Achievements'][$i]['When'] - $prevTime; - if ($distance > 4 * 60 * 60) { - $split[] = $i; - } - $prevTime = $session['Achievements'][$i]['When']; - } - - if (empty($split)) { - $session['EndTime'] = end($session['Achievements'])['When']; - $totalTime += ($session['EndTime'] - $session['StartTime']); - $newSessions[] = $session; - } else { - $split[] = count($session['Achievements']); - $firstIndex = 0; - $isGenerated = false; - foreach ($split as $i) { - if ($i === 0) { - $newSession = [ - 'StartTime' => $session['StartTime'], - 'EndTime' => $session['StartTime'], - 'Achievements' => [], - ]; - } else { - $newSession = [ - 'StartTime' => $isGenerated ? $session['Achievements'][$firstIndex]['When'] : - $session['StartTime'], - 'EndTime' => $session['Achievements'][$i - 1]['When'], - 'Achievements' => array_slice($session['Achievements'], $firstIndex, $i - $firstIndex), - ]; - } - - $newSession['IsGenerated'] = $isGenerated; - $isGenerated = true; - - $totalTime += ($newSession['EndTime'] - $newSession['StartTime']); - $newSessions[] = $newSession; - - $firstIndex = $i; - } - } - } - } - - $sessions = $newSessions; - - return $totalTime; -} diff --git a/public/usergameactivity.php b/public/usergameactivity.php index a0d8c27471..0ff776bed3 100644 --- a/public/usergameactivity.php +++ b/public/usergameactivity.php @@ -59,7 +59,10 @@ echo "$pageTitleAttr"; echo ""; echo ""; - echo ""; + if ($activity['TotalTime'] != $activity['AchievementsTime']) { + echo ""; + } + echo ""; echo ""; echo ""; echo "
User:" . userAvatar($user2, icon: false) . "
Total Playtime:" . formatHMS($activity['TotalTime']) . "$estimated
Total Playtime:" . formatHMS($activity['TotalTime']) . "$estimated
Achievement Playtime:" . formatHMS($activity['AchievementsTime']) . "$estimated
Achievement Sessions:$sessionInfo
Achievements Unlocked:" . $activity['AchievementsUnlocked'] . "$userProgress
"; @@ -96,6 +99,19 @@ echo ""; } + + if (array_key_exists('RichPresence', $session) && !empty($session['RichPresence'])) { + $when = getNiceDate($session['RichPresenceTime']); + $formatted = formatHMS($session['RichPresenceTime'] - $prevWhen); + echo " $when (+$formatted)Rich Presence: {$session['RichPresence']}"; + $prevWhen = $session['RichPresenceTime']; + } + + if ($session['EndTime'] != $prevWhen) { + $when = getNiceDate($session['EndTime']); + $formatted = formatHMS($session['EndTime'] - $prevWhen); + echo " $when (+$formatted)End of session"; + } } echo ""; From f9c1eec28212f2155cbd89a9313a796915d6c3e4 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 27 Oct 2023 11:02:42 -0400 Subject: [PATCH 4/6] refactor(renderAchievementTitle): migrate to blade (#1904) --- app/Helpers/render/achievement.php | 33 +++---------------- public/achievementInfo.php | 15 +++++++-- public/reportissue.php | 13 ++++++-- public/ticketmanager.php | 7 +++- .../community/components/event/aotw.blade.php | 5 +-- .../components/achievement/title.blade.php | 25 ++++++++++++++ .../achievements-list-item.blade.php | 4 +-- 7 files changed, 64 insertions(+), 38 deletions(-) create mode 100644 resources/views/platform/components/achievement/title.blade.php diff --git a/app/Helpers/render/achievement.php b/app/Helpers/render/achievement.php index 6f371c2257..59fba56206 100644 --- a/app/Helpers/render/achievement.php +++ b/app/Helpers/render/achievement.php @@ -1,8 +1,8 @@ ', ['rawTitle' => $label]); } if ($icon !== false) { @@ -61,31 +61,6 @@ function achievementAvatar( ); } -/** - * Render achievement title, parsing `[m]` (missable) as a tag - */ -function renderAchievementTitle(?string $title, bool $tags = true): string -{ - if (!$title) { - return ''; - } - if (!Str::contains($title, '[m]')) { - return $title; - } - - $missableTag = ''; - if ($tags) { - $missableTag = " [m]"; - } - $title = str_replace('[m]', '', $title); - - // If we don't strip consecutive spaces, the - // browser doesn't collapse them in forum
 tags.
-    $title = preg_replace('/\s+/', ' ', $title);
-
-    return trim("$title$missableTag");
-}
-
 function renderAchievementCard(int|string|array $achievement, ?string $context = null, ?string $iconUrl = null): string
 {
     $id = is_int($achievement) || is_string($achievement) ? (int) $achievement : ($achievement['AchievementID'] ?? $achievement['ID'] ?? null);
@@ -103,7 +78,9 @@ function renderAchievementCard(int|string|array $achievement, ?string $context =
         $data = Cache::store('array')->rememberForever('achievement:' . $id . ':card-data', fn () => GetAchievementData($id));
     }
 
-    $title = renderAchievementTitle($data['AchievementTitle'] ?? $data['Title'] ?? null);
+    $title = Blade::render('', [
+        'rawTitle' => $data['AchievementTitle'] ?? $data['Title'] ?? '',
+    ]);
     $description = $data['AchievementDesc'] ?? $data['Description'] ?? null;
     $achPoints = $data['Points'] ?? null;
     $badgeName = $data['BadgeName'] ?? null;
diff --git a/public/achievementInfo.php b/public/achievementInfo.php
index 4e760a8ccc..11cbd84ddf 100644
--- a/public/achievementInfo.php
+++ b/public/achievementInfo.php
@@ -195,9 +195,18 @@ function ResetProgress() {
     ";
 
+    $breadcrumbAchievementTitle = Blade::render('
+        ', [
+        'rawTitle' => $achievementTitle,
+        'isDisplayingTags' => false,
+    ]);
+
     echo "";
 
     echo Blade::render('
@@ -228,7 +237,9 @@ function ResetProgress() {
     echo "";
     echo "
"; - $renderedTitle = renderAchievementTitle($achievementTitle); + $renderedTitle = Blade::render('', [ + 'rawTitle' => $achievementTitle, + ]); echo "
"; echo "
"; diff --git a/public/reportissue.php b/public/reportissue.php index 7b7bab9017..665efb470a 100644 --- a/public/reportissue.php +++ b/public/reportissue.php @@ -56,8 +56,17 @@ function displayCore() {
diff --git a/public/ticketmanager.php b/public/ticketmanager.php index c60571b928..fab95f6217 100644 --- a/public/ticketmanager.php +++ b/public/ticketmanager.php @@ -137,7 +137,12 @@ if (!empty($gameIDGiven)) { echo " » $gameTitle ($consoleName)"; if (!empty($achievementIDGiven)) { - echo " » " . renderAchievementTitle($achievementTitle, tags: false); + echo " » " . Blade::render(' + + ', [ + 'rawTitle' => $achievementTitle, + 'isDisplayingTags' => false, + ]); } } } else { diff --git a/resources/views/community/components/event/aotw.blade.php b/resources/views/community/components/event/aotw.blade.php index 64554474f9..8a946403d0 100644 --- a/resources/views/community/components/event/aotw.blade.php +++ b/resources/views/community/components/event/aotw.blade.php @@ -13,7 +13,6 @@ $achievementRetroPoints = $achievement->TrueRatio; $achievementBadgeName = $achievement->BadgeName; -$renderedAchievementTitle = renderAchievementTitle($achievementName); $renderedGameTitle = renderGameTitle($game->Title); $achievementIconSrc = media_asset("/Badge/$achievementBadgeName.png"); $gameSystemIconUrl = getSystemIconUrl($game->ConsoleID); @@ -33,7 +32,9 @@
- {!! $renderedAchievementTitle !!} + + +

{{ $achievementPoints }} ({{ $achievementRetroPoints }}) Points

{{ $achievement->Description }}

diff --git a/resources/views/platform/components/achievement/title.blade.php b/resources/views/platform/components/achievement/title.blade.php new file mode 100644 index 0000000000..5ca5193b2a --- /dev/null +++ b/resources/views/platform/components/achievement/title.blade.php @@ -0,0 +1,25 @@ +@props([ + 'isDisplayingTags' => true, + 'rawTitle' => '', +]) + + tags. +$processedTitle = preg_replace('/\s+/', ' ', $processedTitle); +?> + +{{ $processedTitle }} +@if ($isDisplayingTags && $containsMissableTag) + + [m] + +@endif diff --git a/resources/views/platform/components/game/achievements-list/achievements-list-item.blade.php b/resources/views/platform/components/game/achievements-list/achievements-list-item.blade.php index 1af624e6da..43000962e1 100644 --- a/resources/views/platform/components/game/achievements-list/achievements-list-item.blade.php +++ b/resources/views/platform/components/game/achievements-list/achievements-list-item.blade.php @@ -35,8 +35,6 @@ tooltip: false ); -$renderedAchievementTitle = renderAchievementTitle($achievement['Title']); - $unlockDate = ''; if (isset($achievement['DateEarned'])) { $unlockDate = Carbon::parse($achievement['DateEarned'])->format('F j Y, g:ia'); @@ -56,7 +54,7 @@
- {!! $renderedAchievementTitle !!} + @if ($achievement['Points'] > 0 || $achievement['TrueRatio'] > 0) From bc6d93d91ee440e68273276654fa20f636c6a039 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Fri, 27 Oct 2023 11:07:15 -0400 Subject: [PATCH 5/6] fix(home): remediate aotw game title rendering issue (#1902) --- resources/views/community/components/event/aotw.blade.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/views/community/components/event/aotw.blade.php b/resources/views/community/components/event/aotw.blade.php index 8a946403d0..4ab5ebb610 100644 --- a/resources/views/community/components/event/aotw.blade.php +++ b/resources/views/community/components/event/aotw.blade.php @@ -43,7 +43,7 @@
- + {{-- Keep the image and game title in a single tooltipped container. Do not tooltip the console name. --}}
- - + {{-- Provide invisible space to slide the console underneath --}} + Console icon From c2c2f9b073a25e036cb5b71316b701295d891cfb Mon Sep 17 00:00:00 2001 From: Jamiras <32680403+Jamiras@users.noreply.github.com> Date: Fri, 27 Oct 2023 09:21:56 -0600 Subject: [PATCH 6/6] add tests for dorequest?r=uploadachievement (#1906) --- app/Helpers/database/achievement.php | 177 ++-- app/Helpers/database/static.php | 25 +- app/Helpers/database/user-activity.php | 22 +- app/Helpers/database/user-permission.php | 15 +- app/Helpers/database/user.php | 15 +- app/Platform/EventServiceProvider.php | 2 + ...tchUpdateDeveloperContributionYieldJob.php | 20 +- .../DispatchUpdateGameMetricsJob.php | 5 + .../Feature/Connect/UploadAchievementTest.php | 904 ++++++++++++++++++ 9 files changed, 1014 insertions(+), 171 deletions(-) create mode 100644 tests/Feature/Connect/UploadAchievementTest.php diff --git a/app/Helpers/database/achievement.php b/app/Helpers/database/achievement.php index efb5d8f900..b5e7f1d0ca 100644 --- a/app/Helpers/database/achievement.php +++ b/app/Helpers/database/achievement.php @@ -205,11 +205,6 @@ function UploadNewAchievement( return false; } - $dbAuthor = $author; - $rawDesc = $desc; - $rawTitle = $title; - sanitize_sql_inputs($title, $desc, $mem, $progress, $progressMax, $progressFmt, $dbAuthor, $type); - $typeValue = ""; if ($type === null || trim($type) === '' || $type === 'not-given') { $typeValue = "NULL"; @@ -226,67 +221,84 @@ function UploadNewAchievement( return false; } - $query = " - INSERT INTO Achievements ( - ID, GameID, Title, Description, - MemAddr, Progress, ProgressMax, - ProgressFormat, Points, Flags, type, - Author, DateCreated, DateModified, - Updated, VotesPos, VotesNeg, - BadgeName, DisplayOrder, AssocVideo, - TrueRatio - ) - VALUES ( - NULL, '$gameID', '$title', '$desc', - '$mem', '$progress', '$progressMax', - '$progressFmt', $points, $flag, $typeValue, - '$dbAuthor', NOW(), NOW(), - NOW(), 0, 0, - '$badge', 0, NULL, - 0 - )"; - $db = getMysqliConnection(); - if (mysqli_query($db, $query) !== false) { - $idInOut = mysqli_insert_id($db); - postActivity($author, ActivityType::UploadAchievement, $idInOut); - - static_addnewachievement($idInOut); - addArticleComment( - "Server", - ArticleType::Achievement, - $idInOut, - "$author uploaded this achievement.", - $author - ); - - // uploaded new achievement - AchievementCreated::dispatch(Achievement::find($idInOut)); - - return true; - } + $achievement = new Achievement(); + $achievement->GameID = $gameID; + $achievement->Title = $title; + $achievement->Description = $desc; + $achievement->MemAddr = $mem; + $achievement->Points = $points; + $achievement->Flags = $flag; + $achievement->type = ($typeValue == 'NULL') ? null : $type; + $achievement->Author = $author; + $achievement->BadgeName = $badge; + + $achievement->save(); + $idInOut = $achievement->ID; + postActivity($author, ActivityType::UploadAchievement, $idInOut); + + static_addnewachievement($idInOut); + addArticleComment( + "Server", + ArticleType::Achievement, + $idInOut, + "$author uploaded this achievement.", + $author + ); + + // uploaded new achievement + AchievementCreated::dispatch($achievement); - // failed - return false; + return true; } + // Achievement being updated - $query = "SELECT Flags, type, MemAddr, Points, Title, Description, BadgeName, Author FROM Achievements WHERE ID='$idInOut'"; - $dbResult = s_mysql_query($query); - if ($dbResult !== false && mysqli_num_rows($dbResult) == 1) { - $data = mysqli_fetch_assoc($dbResult); + $achievement = Achievement::find($idInOut); + if ($achievement) { + $fields = []; + + $changingPoints = ($achievement->Points != $points); + if ($changingPoints) { + $achievement->Points = $points; + $fields[] = "points"; + } + + if ($achievement->BadgeName !== $badge) { + $achievement->BadgeName = $badge; + $fields[] = "badge"; + } + + if ($achievement->Title !== $title) { + $achievement->Title = $title; + $fields[] = "title"; + } + + if ($achievement->Description !== $desc) { + $achievement->Description = $desc; + $fields[] = "description"; + } + + $changingType = ($achievement->type != $type && $type !== 'not-given'); + if ($changingType) { + $achievement->type = $type; + $fields[] = "type"; + } - $changingAchSet = ($data['Flags'] != $flag); - $changingType = ($data['type'] != $type && $type !== 'not-given'); - $changingPoints = ($data['Points'] != $points); - $changingTitle = ($data['Title'] !== $rawTitle); - $changingDescription = ($data['Description'] !== $rawDesc); - $changingBadge = ($data['BadgeName'] !== $badge); - $changingLogic = ($data['MemAddr'] != $mem); + $changingLogic = ($achievement->MemAddr != $mem); + if ($changingLogic) { + $achievement->MemAddr = $mem; + $fields[] = "logic"; + } + + $changingAchSet = ($achievement->Flags != $flag); + if ($changingAchSet) { + $achievement->Flags = $flag; + } if ($flag === AchievementFlag::OfficialCore || $changingAchSet) { // If modifying core or changing achievement state // changing ach set detected; user is $author, permissions is $userPermissions, target set is $flag // Only allow jr. devs to modify core achievements if they are the author and not updating logic or state - if ($userPermissions < Permissions::Developer && ($changingLogic || $changingAchSet || $data['Author'] !== $author)) { + if ($userPermissions < Permissions::Developer && ($changingLogic || $changingAchSet || $achievement->Author !== $author)) { // Must be developer to modify core logic! $errorOut = "You must be a developer to perform this action! Please drop a message in the forums to apply."; @@ -296,42 +308,21 @@ function UploadNewAchievement( if ($flag === AchievementFlag::Unofficial) { // If modifying unofficial // Only allow jr. devs to modify unofficial if they are the author - if ($userPermissions == Permissions::JuniorDeveloper && $data['Author'] !== $author) { + if ($userPermissions == Permissions::JuniorDeveloper && $achievement->Author !== $author) { $errorOut = "You must be a developer to perform this action! Please drop a message in the forums to apply."; return false; } } - // `null` is a valid type value, so we use a different fallback value. - if ($type === 'not-given' && $data['type'] !== null) { - $typeValue = "'" . $data['type'] . "'"; - } - - $query = "UPDATE Achievements SET Title='$title', Description='$desc', Progress='$progress', ProgressMax='$progressMax', ProgressFormat='$progressFmt', MemAddr='$mem', Points=$points, Flags=$flag, type=$typeValue, DateModified=NOW(), Updated=NOW(), BadgeName='$badge' WHERE ID=$idInOut"; - - $db = getMysqliConnection(); - if (mysqli_query($db, $query) !== false) { - // if ($changingAchSet || $changingPoints) { - // // When changing achievement set, all existing achievements that rely on this should be purged. - // // $query = "DELETE FROM Awarded WHERE ID='$idInOut'"; - // // nah, that's a bit harsh... esp if you're changing something tiny like the badge!! - // - // // if (s_mysql_query($query) !== false) { - // // $rowsAffected = mysqli_affected_rows($db); - // // // great - // // } else { - // // //meh - // // } - // } + if ($achievement->isDirty()) { + $achievement->save(); static_setlastupdatedgame($gameID); static_setlastupdatedachievement($idInOut); postActivity($author, ActivityType::EditAchievement, $idInOut); - $achievement = Achievement::find($idInOut); - if ($changingAchSet) { if ($flag === AchievementFlag::OfficialCore) { addArticleComment( @@ -354,25 +345,6 @@ function UploadNewAchievement( } expireGameTopAchievers($gameID); } else { - $fields = []; - if ($changingPoints) { - $fields[] = "points"; - } - if ($changingBadge) { - $fields[] = "badge"; - } - if ($changingLogic) { - $fields[] = "logic"; - } - if ($changingTitle) { - $fields[] = "title"; - } - if ($changingDescription) { - $fields[] = "description"; - } - if ($changingType) { - $fields[] = "type"; - } $editString = implode(', ', $fields); if (!empty($editString)) { @@ -392,12 +364,9 @@ function UploadNewAchievement( if ($changingType) { AchievementTypeChanged::dispatch($achievement); } - - return true; } - log_sql_fail(); - return false; + return true; } return false; diff --git a/app/Helpers/database/static.php b/app/Helpers/database/static.php index 53e10dd1ce..c510592436 100644 --- a/app/Helpers/database/static.php +++ b/app/Helpers/database/static.php @@ -8,12 +8,9 @@ */ function static_addnewachievement(int $id): void { - $query = "UPDATE StaticData AS sd "; - $query .= "SET sd.NumAchievements=sd.NumAchievements+1, sd.LastCreatedAchievementID='$id'"; - $dbResult = s_mysql_query($query); - if (!$dbResult) { - log_sql_fail(); - } + $query = "UPDATE StaticData "; + $query .= "SET NumAchievements=NumAchievements+1, LastCreatedAchievementID=$id"; + legacyDbStatement($query); } /** @@ -113,12 +110,8 @@ function static_setlastearnedachievement(int $id, string $user, int $points): vo */ function static_setlastupdatedgame(int $id): void { - $query = "UPDATE StaticData AS sd "; - $query .= "SET sd.LastUpdatedGameID = '$id'"; - $dbResult = s_mysql_query($query); - if (!$dbResult) { - log_sql_fail(); - } + $query = "UPDATE StaticData SET LastUpdatedGameID = $id"; + legacyDbStatement($query); } /** @@ -126,10 +119,6 @@ function static_setlastupdatedgame(int $id): void */ function static_setlastupdatedachievement(int $id): void { - $query = "UPDATE StaticData AS sd "; - $query .= "SET sd.LastUpdatedAchievementID = '$id'"; - $dbResult = s_mysql_query($query); - if (!$dbResult) { - log_sql_fail(); - } + $query = "UPDATE StaticData SET LastUpdatedAchievementID = $id"; + legacyDbStatement($query); } diff --git a/app/Helpers/database/user-activity.php b/app/Helpers/database/user-activity.php index 7d6ed5a479..7119e61cc5 100644 --- a/app/Helpers/database/user-activity.php +++ b/app/Helpers/database/user-activity.php @@ -169,8 +169,6 @@ function addArticleComment( return false; } - sanitize_sql_inputs($commentPayload); - // Note: $user is the person who just made a comment. $userID = getUserIDFromUser($user); @@ -183,33 +181,27 @@ function addArticleComment( return true; } - // Replace all single quotes with double quotes (to work with MYSQL DB) - // $commentPayload = str_replace( "'", "''", $commentPayload ); - if (is_array($articleID)) { + $bindings = []; + $articleIDs = $articleID; $arrayCount = count($articleID); $count = 0; $query = "INSERT INTO Comment (ArticleType, ArticleID, UserID, Payload) VALUES"; foreach ($articleID as $id) { - $query .= "( $articleType, $id, $userID, '$commentPayload' )"; + $bindings['commentPayload' . $count] = $commentPayload; + $query .= "( $articleType, $id, $userID, :commentPayload$count )"; if (++$count !== $arrayCount) { $query .= ","; } } } else { - $query = "INSERT INTO Comment (ArticleType, ArticleID, UserID, Payload) VALUES( $articleType, $articleID, $userID, '$commentPayload' )"; + $query = "INSERT INTO Comment (ArticleType, ArticleID, UserID, Payload) VALUES( $articleType, $articleID, $userID, :commentPayload)"; + $bindings = ['commentPayload' => $commentPayload]; $articleIDs = [$articleID]; } - $db = getMysqliConnection(); - $dbResult = mysqli_query($db, $query); - - if (!$dbResult) { - log_sql_fail(); - - return false; - } + legacyDbStatement($query, $bindings); // Inform Subscribers of this comment: foreach ($articleIDs as $id) { diff --git a/app/Helpers/database/user-permission.php b/app/Helpers/database/user-permission.php index 70506b41a5..60d165068e 100644 --- a/app/Helpers/database/user-permission.php +++ b/app/Helpers/database/user-permission.php @@ -9,19 +9,10 @@ function getUserPermissions(?string $user): int return 0; } - sanitize_sql_inputs($user); - - $query = "SELECT Permissions FROM UserAccounts WHERE User='$user'"; - $dbResult = s_mysql_query($query); - if (!$dbResult) { - log_sql_fail(); - - return 0; - } - - $data = mysqli_fetch_assoc($dbResult); + $query = "SELECT Permissions FROM UserAccounts WHERE User=:user"; + $row = legacyDbFetch($query, ['user' => $user]); - return (int) $data['Permissions']; + return $row ? (int) $row['Permissions'] : Permissions::Unregistered; } function SetAccountPermissionsJSON( diff --git a/app/Helpers/database/user.php b/app/Helpers/database/user.php index 814cea4b62..59f2b59cb2 100644 --- a/app/Helpers/database/user.php +++ b/app/Helpers/database/user.php @@ -45,19 +45,10 @@ function getUserIDFromUser(?string $user): int return 0; } - sanitize_sql_inputs($user); - - $query = "SELECT ID FROM UserAccounts WHERE User LIKE '$user'"; - $dbResult = s_mysql_query($query); - - if ($dbResult !== false) { - $data = mysqli_fetch_assoc($dbResult); - - return (int) ($data['ID'] ?? 0); - } + $query = "SELECT ID FROM UserAccounts WHERE User = :user"; + $row = legacyDbFetch($query, ['user' => $user]); - // cannot find user $user - return 0; + return $row ? (int) $row['ID'] : 0; } function getUserMetadataFromID(int $userID): ?array diff --git a/app/Platform/EventServiceProvider.php b/app/Platform/EventServiceProvider.php index 0b8b02b5d1..6efc0ef20f 100755 --- a/app/Platform/EventServiceProvider.php +++ b/app/Platform/EventServiceProvider.php @@ -22,6 +22,7 @@ use App\Platform\Events\PlayerMetricsUpdated; use App\Platform\Events\PlayerRankedStatusChanged; use App\Platform\Events\PlayerSessionHeartbeat; +// use App\Platform\Listeners\DispatchUpdateDeveloperContributionYieldJob; use App\Platform\Listeners\DispatchUpdateGameMetricsJob; use App\Platform\Listeners\DispatchUpdatePlayerGameMetricsJob; use App\Platform\Listeners\DispatchUpdatePlayerMetricsJob; @@ -34,6 +35,7 @@ class EventServiceProvider extends ServiceProvider { protected $listen = [ AchievementCreated::class => [ + DispatchUpdateGameMetricsJob::class, // dispatches GameMetricsUpdated ], AchievementPublished::class => [ DispatchUpdateGameMetricsJob::class, // dispatches GameMetricsUpdated diff --git a/app/Platform/Listeners/DispatchUpdateDeveloperContributionYieldJob.php b/app/Platform/Listeners/DispatchUpdateDeveloperContributionYieldJob.php index e8433151ee..ab5b1a7241 100644 --- a/app/Platform/Listeners/DispatchUpdateDeveloperContributionYieldJob.php +++ b/app/Platform/Listeners/DispatchUpdateDeveloperContributionYieldJob.php @@ -17,16 +17,16 @@ public function handle(object $event): void $user = null; switch ($event::class) { - // TODO case AchievementPublished::class: - // $achievement = $event->achievement; - // $achievement->loadMissing('developer'); - // $user = $achievement->developer; - // break; - // TODO case AchievementUnpublished::class: - // $achievement = $event->achievement; - // $achievement->loadMissing('developer'); - // $user = $achievement->developer; - // break; + case AchievementPublished::class: + $achievement = $event->achievement; + $achievement->loadMissing('developer'); + $user = $achievement->developer; + break; + case AchievementUnpublished::class: + $achievement = $event->achievement; + $achievement->loadMissing('developer'); + $user = $achievement->developer; + break; case AchievementPointsChanged::class: $achievement = $event->achievement; $achievement->loadMissing('developer'); diff --git a/app/Platform/Listeners/DispatchUpdateGameMetricsJob.php b/app/Platform/Listeners/DispatchUpdateGameMetricsJob.php index af9edcb98f..8de9eeabf9 100644 --- a/app/Platform/Listeners/DispatchUpdateGameMetricsJob.php +++ b/app/Platform/Listeners/DispatchUpdateGameMetricsJob.php @@ -2,6 +2,7 @@ namespace App\Platform\Listeners; +use App\Platform\Events\AchievementCreated; use App\Platform\Events\AchievementPointsChanged; use App\Platform\Events\AchievementPublished; use App\Platform\Events\AchievementTypeChanged; @@ -35,6 +36,10 @@ public function handle(object $event): void $achievement = $event->achievement; $game = $achievement->game; break; + case AchievementCreated::class: + $achievement = $event->achievement; + $game = $achievement->game; + break; case PlayerGameMetricsUpdated::class: $game = $event->game; break; diff --git a/tests/Feature/Connect/UploadAchievementTest.php b/tests/Feature/Connect/UploadAchievementTest.php new file mode 100644 index 0000000000..4c52d3b48a --- /dev/null +++ b/tests/Feature/Connect/UploadAchievementTest.php @@ -0,0 +1,904 @@ +create([ + 'Permissions' => Permissions::Developer, + 'appToken' => Str::random(16), + 'ContribCount' => 0, + 'ContribYield' => 0, + ]); + $game = $this->seedGame(withHash: false); + + /** @var Achievement $achievement1 */ + $achievement1 = Achievement::factory()->create(['GameID' => $game->ID + 1, 'Author' => $author->User]); + + AchievementSetClaim::factory()->create(['User' => $author->User, 'GameID' => $game->ID]); + + $params = [ + 'u' => $author->User, + 't' => $author->appToken, + 'g' => $game->ID, + 'n' => 'Title1', + 'd' => 'Description1', + 'z' => 5, + 'm' => '0xH0000=1', + 'f' => 5, // Unofficial - hardcode for test to prevent false success if enum changes + 'b' => '001234', + ]; + + // ==================================================== + // create an achievement + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID + 1, + 'Error' => '', + ]); + + /** @var Achievement $achievement2 */ + $achievement2 = Achievement::findOrFail($achievement1->ID + 1); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title1'); + $this->assertEquals($achievement2->MemAddr, '0xH0000=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertNull($achievement2->type); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertNull($achievement2->user_id); + $this->assertEquals($achievement2->BadgeName, '001234'); + + $game->refresh(); + $this->assertEquals($game->achievements_published, 0); + $this->assertEquals($game->achievements_unpublished, 1); + $this->assertEquals($game->points_total, 0); + + // ==================================================== + // publish achievement + $params['a'] = $achievement2->ID; + $params['f'] = 3; // Official - hardcode for test to prevent false success if enum changes + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title1'); + $this->assertEquals($achievement2->MemAddr, '0xH0000=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::OfficialCore); + $this->assertNull($achievement2->type); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '001234'); + + $game->refresh(); + $this->assertEquals($game->achievements_published, 1); + $this->assertEquals($game->achievements_unpublished, 0); + $this->assertEquals($game->points_total, 5); + + // ==================================================== + // modify achievement + $params['n'] = 'Title2'; + $params['d'] = 'Description2'; + $params['z'] = 10; + $params['m'] = '0xH0001=1'; + $params['b'] = '002345'; + $params['x'] = 'progression'; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::OfficialCore); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + $game->refresh(); + $this->assertEquals($game->achievements_published, 1); + $this->assertEquals($game->achievements_unpublished, 0); + $this->assertEquals($game->points_total, 10); + + // ==================================================== + // unlock achievement; contrib yield changes + $this->addHardcoreUnlock($author, $achievement2); + $this->addHardcoreUnlock($this->user, $achievement2); + + $author->refresh(); + // When DispatchUpdateDeveloperContributionYieldJob is enabled, update all + // of the TODO blocks in this file to expect the contribution changes. + $this->assertEquals($author->ContribCount, 0); + /* TODO + $this->assertEquals($author->ContribCount, 1); + $this->assertEquals($author->ContribYield, 10); + */ + + $game->refresh(); + $this->assertEquals($game->players_total, 2); + $this->assertEquals($game->players_hardcore, 2); + + // ==================================================== + // rescore achievement; contrib yield changes + $params['z'] = 5; + unset($params['x']); // ommitting optional 'x' parameter should not change type + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::OfficialCore); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + $game->refresh(); + $this->assertEquals($game->achievements_published, 1); + $this->assertEquals($game->achievements_unpublished, 0); + $this->assertEquals($game->points_total, 5); + $this->assertEquals($game->players_total, 2); + $this->assertEquals($game->players_hardcore, 2); + + $author->refresh(); + /* TODO + $this->assertEquals($author->ContribCount, 1); + $this->assertEquals($author->ContribYield, 5); + */ + + // ==================================================== + // demote achievement; contrib yield changes + $params['f'] = 5; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + $game->refresh(); + $this->assertEquals($game->achievements_published, 0); + $this->assertEquals($game->achievements_unpublished, 1); + $this->assertEquals($game->points_total, 0); + $this->assertEquals($game->players_total, 0); + $this->assertEquals($game->players_hardcore, 0); + + $author->refresh(); + $this->assertEquals($author->ContribCount, 0); + $this->assertEquals($author->ContribYield, 0); + + // ==================================================== + // change points while demoted + $params['z'] = 10; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + $game->refresh(); + $this->assertEquals($game->achievements_published, 0); + $this->assertEquals($game->achievements_unpublished, 1); + $this->assertEquals($game->points_total, 0); + $this->assertEquals($game->players_total, 0); + $this->assertEquals($game->players_hardcore, 0); + + $author->refresh(); + $this->assertEquals($author->ContribCount, 0); + $this->assertEquals($author->ContribYield, 0); + + // ==================================================== + // repromote achievement; contrib yield changes + $params['f'] = 3; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::OfficialCore); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + $game->refresh(); + $this->assertEquals($game->achievements_published, 1); + $this->assertEquals($game->achievements_unpublished, 0); + $this->assertEquals($game->points_total, 10); + $this->assertEquals($game->players_total, 2); + $this->assertEquals($game->players_hardcore, 2); + + $author->refresh(); + /* TODO + $this->assertEquals($author->ContribCount, 1); + $this->assertEquals($author->ContribYield, 10); + */ + } + + public function testNonDevPermissions(): void + { + /** @var User $author */ + $author = User::factory()->create([ + 'Permissions' => Permissions::Registered, + 'appToken' => Str::random(16), + ]); + $game = $this->seedGame(withHash: false); + + /** @var Achievement $achievement1 */ + $achievement1 = Achievement::factory()->create(['GameID' => $game->ID, 'Author' => $author->User]); + + $params = [ + 'u' => $author->User, + 't' => $author->appToken, + 'g' => $game->ID, + 'n' => 'Title1', + 'd' => 'Description1', + 'z' => 5, + 'm' => '0xH0000=1', + 'f' => 5, // Unofficial - hardcode for test to prevent false success if enum changes + 'b' => '001234', + ]; + + // ==================================================== + // non-developer cannot create achievements + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => 0, + 'Error' => "You must be a developer to perform this action! Please drop a message in the forums to apply.", + ]); + } + + public function testJrDevPermissions(): void + { + /** @var User $author */ + $author = User::factory()->create([ + 'Permissions' => Permissions::JuniorDeveloper, + 'appToken' => Str::random(16), + ]); + $game = $this->seedGame(withHash: false); + + /** @var Achievement $achievement1 */ + $achievement1 = Achievement::factory()->create(['GameID' => $game->ID, 'Author' => $this->user->User]); + + $params = [ + 'u' => $author->User, + 't' => $author->appToken, + 'g' => $game->ID, + 'n' => 'Title1', + 'd' => 'Description1', + 'z' => 5, + 'm' => '0xH0000=1', + 'f' => 5, // Unofficial - hardcode for test to prevent false success if enum changes + 'b' => '001234', + ]; + + // ==================================================== + // junior developer cannot create achievement without claim + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => 0, + 'Error' => "You must have an active claim on this game to perform this action.", + ]); + + // ==================================================== + // junior developer can create achievement with claim + AchievementSetClaim::factory()->create(['User' => $author->User, 'GameID' => $game->ID]); + + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID + 1, + 'Error' => '', + ]); + + /** @var Achievement $achievement2 */ + $achievement2 = Achievement::findOrFail($achievement1->ID + 1); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title1'); + $this->assertEquals($achievement2->MemAddr, '0xH0000=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertNull($achievement2->type); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '001234'); + + // ==================================================== + // junior developer can modify their own achievement + $params['a'] = $achievement2->ID; + $params['n'] = 'Title2'; + $params['d'] = 'Description2'; + $params['z'] = 10; + $params['m'] = '0xH0001=1'; + $params['b'] = '002345'; + $params['x'] = 'progression'; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID + 1, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + // ==================================================== + // junior developer cannot modify an achievement owned by someone else + $params['a'] = $achievement1->ID; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => $achievement1->ID, + 'Error' => 'You must be a developer to perform this action! Please drop a message in the forums to apply.', + ]); + + $achievement1->refresh(); + $this->assertNotEquals($achievement1->Title, 'Title2'); + $this->assertNotEquals($achievement1->MemAddr, '0xH0001=1'); + $this->assertNotEquals($achievement1->Points, 10); + $this->assertEquals($achievement1->Flags, AchievementFlag::Unofficial); + $this->assertNotEquals($achievement1->type, 'progression'); + $this->assertNotEquals($achievement1->Author, $author->User); + $this->assertNotEquals($achievement1->BadgeName, '002345'); + + // ==================================================== + // junior developer cannot promote their own achievement + $params['a'] = $achievement2->ID; + $params['f'] = 3; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => $achievement2->ID, + 'Error' => 'You must be a developer to perform this action! Please drop a message in the forums to apply.', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + // ==================================================== + // junior developer cannot demote their own achievement + $achievement2->Flags = AchievementFlag::OfficialCore; + $achievement2->save(); + $params['f'] = 5; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => $achievement2->ID, + 'Error' => 'You must be a developer to perform this action! Please drop a message in the forums to apply.', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::OfficialCore); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + // ==================================================== + // junior developer cannot change logic of their own achievement in core + $params['f'] = 3; + $params['m'] = '0xH0002=1'; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => $achievement2->ID, + 'Error' => 'You must be a developer to perform this action! Please drop a message in the forums to apply.', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::OfficialCore); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + // ==================================================== + // junior developer can change all non-logic of their own achievement in core + $params['n'] = 'Title3'; + $params['d'] = 'Description3'; + $params['z'] = 5; + $params['m'] = '0xH0001=1'; + $params['b'] = '003456'; + $params['x'] = ''; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title3'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::OfficialCore); + $this->assertNull($achievement2->type); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '003456'); + } + + public function testDevPermissions(): void + { + /** @var User $author */ + $author = User::factory()->create([ + 'Permissions' => Permissions::Developer, + 'appToken' => Str::random(16), + ]); + $game = $this->seedGame(withHash: false); + + /** @var Achievement $achievement1 */ + $achievement1 = Achievement::factory()->create(['GameID' => $game->ID, 'Author' => $this->user->User]); + + $params = [ + 'u' => $author->User, + 't' => $author->appToken, + 'g' => $game->ID, + 'n' => 'Title1', + 'd' => 'Description1', + 'z' => 5, + 'm' => '0xH0000=1', + 'f' => 5, // Unofficial - hardcode for test to prevent false success if enum changes + 'b' => '001234', + ]; + + // ==================================================== + // developer cannot create achievement without claim + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => 0, + 'Error' => "You must have an active claim on this game to perform this action.", + ]); + + // ==================================================== + // developer can create achievement with claim + AchievementSetClaim::factory()->create(['User' => $author->User, 'GameID' => $game->ID]); + + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID + 1, + 'Error' => '', + ]); + + /** @var Achievement $achievement2 */ + $achievement2 = Achievement::findOrFail($achievement1->ID + 1); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title1'); + $this->assertEquals($achievement2->MemAddr, '0xH0000=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertNull($achievement2->type); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '001234'); + + // ==================================================== + // developer can modify their own achievement + $params['a'] = $achievement2->ID; + $params['n'] = 'Title2'; + $params['d'] = 'Description2'; + $params['z'] = 10; + $params['m'] = '0xH0001=1'; + $params['b'] = '002345'; + $params['x'] = 'progression'; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID + 1, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + // ==================================================== + // developer can promote their own achievement + $params['f'] = 3; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::OfficialCore); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + // ==================================================== + // developer can change all properties of their own achievement in core + $params['n'] = 'Title3'; + $params['d'] = 'Description3'; + $params['z'] = 5; + $params['m'] = '0xH0002=1'; + $params['b'] = '003456'; + $params['x'] = ''; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title3'); + $this->assertEquals($achievement2->MemAddr, '0xH0002=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::OfficialCore); + $this->assertNull($achievement2->type); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '003456'); + + // ==================================================== + // developer can demote their own achievement + $params['f'] = 5; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title3'); + $this->assertEquals($achievement2->MemAddr, '0xH0002=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertNull($achievement2->type); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '003456'); + + // ==================================================== + // developer can modify an achievement owned by someone else + $params['a'] = $achievement1->ID; + $params['n'] = 'Title2'; + $params['d'] = 'Description2'; + $params['z'] = 10; + $params['m'] = '0xH0001=1'; + $params['b'] = '002345'; + $params['x'] = 'progression'; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID, + 'Error' => '', + ]); + + $achievement1->refresh(); + $this->assertEquals($achievement1->Title, 'Title2'); + $this->assertEquals($achievement1->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement1->Points, 10); + $this->assertEquals($achievement1->Flags, AchievementFlag::Unofficial); + $this->assertEquals($achievement1->type, 'progression'); + $this->assertEquals($achievement1->Author, $this->user->User); + $this->assertEquals($achievement1->BadgeName, '002345'); + + // ==================================================== + // developer can promote someone else's achievement + $params['f'] = 3; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID, + 'Error' => '', + ]); + + $achievement1->refresh(); + $this->assertEquals($achievement1->GameID, $game->ID); + $this->assertEquals($achievement1->Title, 'Title2'); + $this->assertEquals($achievement1->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement1->Points, 10); + $this->assertEquals($achievement1->Flags, AchievementFlag::OfficialCore); + $this->assertEquals($achievement1->type, 'progression'); + $this->assertEquals($achievement1->Author, $this->user->User); + $this->assertEquals($achievement1->BadgeName, '002345'); + + // ==================================================== + // developer can change all properties of someone else's achievement in core + $params['n'] = 'Title3'; + $params['d'] = 'Description3'; + $params['z'] = 5; + $params['m'] = '0xH0002=1'; + $params['b'] = '003456'; + $params['x'] = ''; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID, + 'Error' => '', + ]); + + $achievement1->refresh(); + $this->assertEquals($achievement1->GameID, $game->ID); + $this->assertEquals($achievement1->Title, 'Title3'); + $this->assertEquals($achievement1->MemAddr, '0xH0002=1'); + $this->assertEquals($achievement1->Points, 5); + $this->assertEquals($achievement1->Flags, AchievementFlag::OfficialCore); + $this->assertNull($achievement1->type); + $this->assertEquals($achievement1->Author, $this->user->User); + $this->assertEquals($achievement1->BadgeName, '003456'); + + // ==================================================== + // developer can demote someone else's achievement + $params['f'] = 5; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID, + 'Error' => '', + ]); + + $achievement1->refresh(); + $this->assertEquals($achievement1->GameID, $game->ID); + $this->assertEquals($achievement1->Title, 'Title3'); + $this->assertEquals($achievement1->MemAddr, '0xH0002=1'); + $this->assertEquals($achievement1->Points, 5); + $this->assertEquals($achievement1->Flags, AchievementFlag::Unofficial); + $this->assertNull($achievement1->type); + $this->assertEquals($achievement1->Author, $this->user->User); + $this->assertEquals($achievement1->BadgeName, '003456'); + } + + public function testRolloutConsole(): void + { + /** @var User $author */ + $author = User::factory()->create([ + 'Permissions' => Permissions::Developer, + 'appToken' => Str::random(16), + ]); + /** @var System $system */ + $system = System::factory()->create(['ID' => 500]); + $game = $this->seedGame(system: $system, withHash: false); + + AchievementSetClaim::factory()->create(['User' => $author->User, 'GameID' => $game->ID]); + + /** @var Achievement $achievement1 */ + $achievement1 = Achievement::factory()->create(['GameID' => $game->ID + 1, 'Author' => $author->User]); + + $params = [ + 'u' => $author->User, + 't' => $author->appToken, + 'g' => $game->ID, + 'n' => 'Title1', + 'd' => 'Description1', + 'z' => 5, + 'm' => '0xH0000=1', + 'f' => 5, + 'b' => '001234', + ]; + + // ==================================================== + // can upload to unofficial + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement1->ID + 1, + 'Error' => '', + ]); + + /** @var Achievement $achievement2 */ + $achievement2 = Achievement::findOrFail($achievement1->ID + 1); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title1'); + $this->assertEquals($achievement2->MemAddr, '0xH0000=1'); + $this->assertEquals($achievement2->Points, 5); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertNull($achievement2->type); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertNull($achievement2->user_id); + $this->assertEquals($achievement2->BadgeName, '001234'); + + $game->refresh(); + $this->assertEquals($game->achievements_published, 0); + $this->assertEquals($game->achievements_unpublished, 1); + $this->assertEquals($game->points_total, 0); + + // ==================================================== + // can modify in unofficial + $params['a'] = $achievement2->ID; + $params['n'] = 'Title2'; + $params['d'] = 'Description2'; + $params['z'] = 10; + $params['m'] = '0xH0001=1'; + $params['b'] = '002345'; + $params['x'] = 'progression'; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement2->ID, + 'Error' => '', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + + $game->refresh(); + $this->assertEquals($game->achievements_published, 0); + $this->assertEquals($game->achievements_unpublished, 1); + $this->assertEquals($game->points_total, 0); + + // ==================================================== + // cannot promote for rollout console + $params['f'] = 3; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => $achievement2->ID, + 'Error' => 'You cannot promote achievements for a game from an unsupported console (console ID: 500).', + ]); + + $achievement2->refresh(); + $this->assertEquals($achievement2->GameID, $game->ID); + $this->assertEquals($achievement2->Title, 'Title2'); + $this->assertEquals($achievement2->MemAddr, '0xH0001=1'); + $this->assertEquals($achievement2->Points, 10); + $this->assertEquals($achievement2->Flags, AchievementFlag::Unofficial); + $this->assertEquals($achievement2->type, 'progression'); + $this->assertEquals($achievement2->Author, $author->User); + $this->assertEquals($achievement2->BadgeName, '002345'); + } + + public function testOtherErrors(): void + { + /** @var User $author */ + $author = User::factory()->create([ + 'Permissions' => Permissions::Developer, + 'appToken' => Str::random(16), + ]); + $game = $this->seedGame(withHash: false); + + AchievementSetClaim::factory()->create(['User' => $author->User, 'GameID' => $game->ID]); + + $params = [ + 'u' => $author->User, + 't' => $author->appToken, + 'g' => $game->ID, + 'n' => 'Title1', + 'd' => 'Description1', + 'z' => 5, + 'm' => '0xH0000=1', + 'f' => 5, + 'b' => '001234', + ]; + + // ==================================================== + // invalid flag + $params['f'] = 4; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => 0, + 'Error' => 'Invalid achievement flag', + ]); + + // ==================================================== + // invalid points + $params['f'] = 5; + $params['z'] = 15; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => 0, + 'Error' => 'Invalid points value (15).', + ]); + + // ==================================================== + // invalid type + $params['z'] = 10; + $params['x'] = 'unknown'; + $this->get($this->apiUrl('uploadachievement', $params)) + ->assertExactJson([ + 'Success' => false, + 'AchievementID' => 0, + 'Error' => 'Invalid achievement type', + ]); + } +}