Skip to content

Commit

Permalink
Implement top scores API
Browse files Browse the repository at this point in the history
This new endpoint retrieves the user's personal best on a given map
including scores before and after the PB. It will return a maximum
of N = before + after + 1 scores if possible. The top N entries will be
returned if the user has no PB yet.

The following cURL example fetches a maximum of six scores:
curl -X POST https://board.portal2.local/api-v2/top-scores \
    -F "auth_hash=$BOARD_AUTH_HASH" \
    -F "mapId=2" \
    -F "before=3"  \
    -F "after=2"
  • Loading branch information
NeKzor committed Aug 8, 2024
1 parent 1bfa4f8 commit e0df39d
Show file tree
Hide file tree
Showing 2 changed files with 183 additions and 0 deletions.
149 changes: 149 additions & 0 deletions classes/Leaderboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,121 @@ public static function getBoard($unsafe_parameters = array())
return $board;
}

public static function getLeaderboard(string $mapId)
{
// NOTE: Copy from function "getBoard" above (formatted but not optimized)
$query = Database::query(
"SELECT ranks.profile_number
, u.avatar
, IFNULL(u.boardname, u.steamname) as boardname
, chapters.id as chapterid
, maps.steam_id as mapid
, ranks.profile_number
, ranks.changelog_id
, ranks.score
, ranks.player_rank
, ranks.score_rank
, DATE_FORMAT(ranks.time_gained, '%Y-%m-%dT%TZ') as date
, has_demo
, youtube_id
, ranks.note
, ranks.submission
, ranks.pending
, ranks.autorender_id
FROM usersnew as u
JOIN (
SELECT sc.changelog_id
, sc.profile_number
, sc.score
, sc.map_id
, sc.time_gained
, sc.has_demo
, sc.youtube_id
, sc.submission
, sc.note
, sc.pending
, sc.autorender_id
, RANK() OVER (
PARTITION BY sc.map_id
ORDER BY sc.score
) as player_rank
, DENSE_RANK() OVER (
PARTITION BY sc.map_id
ORDER BY sc.score
) as score_rank
FROM (
SELECT changelog.submission
, scores.changelog_id
, scores.profile_number
, scores.map_id
, changelog.score
, changelog.time_gained
, changelog.youtube_id
, changelog.has_demo
, changelog.note
, changelog.pending
, changelog.autorender_id
FROM scores
INNER JOIN changelog
ON (scores.changelog_id = changelog.id)
WHERE scores.profile_number IN (
SELECT profile_number
FROM usersnew
WHERE banned = 0
)
AND scores.map_id = ?
AND changelog.banned = '0'
AND changelog.pending = '0'
) as sc
ORDER BY sc.map_id
, sc.score
, sc.time_gained
, sc.profile_number ASC
) as ranks
ON u.profile_number = ranks.profile_number
JOIN maps
ON ranks.map_id = maps.steam_id
JOIN chapters
ON maps.chapter_id = chapters.id
AND player_rank <= ?
ORDER BY map_id
, score
, time_gained
, profile_number ASC",
"si",
[
$mapId,
self::numTrackedPlayerRanks,
]
);

$board = [];
$idx = 0;

while ($row = $query->fetch_assoc()) {
$board[$idx]["scoreData"]["note"] = $row["note"] != NULL ? htmlspecialchars($row["note"]) : NULL;
$board[$idx]["scoreData"]["submission"] = $row["submission"];
$board[$idx]["scoreData"]["changelogId"] = $row["changelog_id"];
$board[$idx]["scoreData"]["playerRank"] = $row["player_rank"];
$board[$idx]["scoreData"]["scoreRank"] = $row["score_rank"];
// TODO: Remove string cast in the future.
$board[$idx]["scoreData"]["score"] = strval($row["score"]);
$board[$idx]["scoreData"]["date"] = $row["date"];
$board[$idx]["scoreData"]["hasDemo"] = $row["has_demo"];
$board[$idx]["scoreData"]["youtubeId"] = $row["youtube_id"];
$board[$idx]["scoreData"]["pending"] = $row["pending"];
$board[$idx]["scoreData"]["autorenderId"] = $row["autorender_id"];
$board[$idx]["scoreData"]["mapId"] = $row["mapid"];
$board[$idx]["scoreData"]["chapterId"] = $row["chapterid"];
$board[$idx]["userData"]["boardname"] = htmlspecialchars($row["boardname"]);
$board[$idx]["userData"]["avatar"] = $row["avatar"];
$board[$idx]["userData"]["profileNumber"] = $row["profile_number"];
++$idx;
}

return $board;
}

public static function cacheChamberBoards($board) {
foreach ($board as $chapter => $chapterData) {
foreach ($chapterData as $map => $mapData) {
Expand Down Expand Up @@ -1782,6 +1897,40 @@ public static function getLatestPb(string $profile_number, string $map_id) {
return $pb;
}

public static function getTopScores(string $profile_number, string $mapId, int $before, int $after) {
$leaderboard = self::getLeaderboard($mapId);
if (!$leaderboard) {
return [];
}

$profileIds = array_map(
function ($entry) {
return $entry["userData"]["profileNumber"];
},
$leaderboard
);

$offset = 0;
$length = $before + $after + 1;

$pbIndex = array_search($profile_number, $profileIds);
if ($pbIndex === false) {
return array_slice($leaderboard, $offset, $length);
}

$beforeIndex = $pbIndex - $before;
$afterIndex = $pbIndex + $after;

$lastIndex = count($leaderboard) - 1;
$offset = max(0, $beforeIndex);

if ($afterIndex > $lastIndex) {
$offset = max(0, $offset - ($afterIndex - $lastIndex));
}

return array_slice($leaderboard, $offset, $length);
}

private static function isBest(string $profile_number, string $map_id, int $changelogId) {
Debug::log("Profile Number: ".$profile_number." Map Id: ".$map_id." Changelog Id:".$changelogId);

Expand Down
34 changes: 34 additions & 0 deletions classes/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,40 @@ public function processRequest($location)
exit;
}

// Provide scores before and after PB
if ($location[2] == "top-scores") {
if (!strlen($_POST["mapId"] ?? '') || !is_integer(intval($_POST["mapId"]))) {
echo "{\"error\":\"Invalid value for field 'mapId'\"}";
header('Content-Type: application/json');
http_response_code(400);
exit;
}

if (!strlen($_POST["before"] ?? '') || !is_integer(intval($_POST["before"]))) {
echo "{\"error\":\"Invalid value for field 'before'\"}";
header('Content-Type: application/json');
http_response_code(400);
exit;
}

if (!strlen($_POST["after"] ?? '') || !is_integer(intval($_POST["after"]))) {
echo "{\"error\":\"Invalid value for field 'after'\"}";
header('Content-Type: application/json');
http_response_code(400);
exit;
}

echo json_encode(Leaderboard::getTopScores(
$userId,
strval($_POST["mapId"]),
intval($_POST["before"]),
intval($_POST["after"]),
));

header('Content-Type: application/json');
exit;
}

}

// API v3 for bots
Expand Down

0 comments on commit e0df39d

Please sign in to comment.