Skip to content

Commit

Permalink
feat(multiset): send sets to emulators (#2857)
Browse files Browse the repository at this point in the history
  • Loading branch information
wescopeland authored Dec 20, 2024
1 parent d3fbd25 commit 6fc0c05
Show file tree
Hide file tree
Showing 10 changed files with 1,306 additions and 174 deletions.
338 changes: 338 additions & 0 deletions app/Connect/Actions/BuildClientPatchDataAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
<?php

declare(strict_types=1);

namespace App\Connect\Actions;

use App\Models\Achievement;
use App\Models\Game;
use App\Models\GameAchievementSet;
use App\Models\GameHash;
use App\Models\PlayerGame;
use App\Models\User;
use App\Platform\Enums\AchievementFlag;
use App\Platform\Enums\AchievementSetType;
use Illuminate\Database\Eloquent\Collection;
use InvalidArgumentException;

class BuildClientPatchDataAction
{
/**
* Assembles a patch data package of all components needed by emulators:
* - Basic game information (title, system, etc.)
* - Achievement definitions and unlock conditions
* - Leaderboard configurations
* - Rich presence script
*
* Modern rcheevos integrations send the game hash. Legacy integrations send only
* the game. We need to support constructing the patch data package for both situations.
*
* @param GameHash|null $gameHash The game hash to build patch data for
* @param Game|null $game The game to build patch data for
* @param User|null $user The current user requesting the patch data (for player count calculations)
* @param AchievementFlag|null $flag Optional flag to filter the achievements by (eg: only official achievements)
* @throws InvalidArgumentException when neither $gameHash nor $game is provided
*/
public function execute(
?GameHash $gameHash = null,
?Game $game = null,
?User $user = null,
?AchievementFlag $flag = null,
): array {
if (!$gameHash && !$game) {
throw new InvalidArgumentException('Either gameHash or game must be provided to build patch data.');
}

// For legacy clients, just use the game directly.
if (!$gameHash) {
return $this->buildPatchData($game, null, $user, $flag);
}

$hashGame = $gameHash->game;

// If there's no user or the current user has multiset globally disabled, use the hash game.
if (!$user || $user->is_globally_opted_out_of_subsets) {
return $this->buildPatchData($hashGame, null, $user, $flag);
}

// Resolve sets once - we'll use this for building the full patch data.
$resolvedSets = (new ResolveAchievementSetsAction())->execute($gameHash, $user);
if ($resolvedSets->isEmpty()) {
return $this->buildPatchData($hashGame, null, $user, $flag);
}

// Get the core game from the first resolved set.
$coreSet = $resolvedSets->first();
$coreGame = Game::find($coreSet->game_id) ?? $hashGame;

$richPresencePatch = $coreGame->RichPresencePatch;

// Look up if this hash game's achievement set is attached as a subset to the core game
$hashGameSubsetAttachment = GameAchievementSet::where('game_id', $coreGame->id)
->where('achievement_set_id', $hashGame->gameAchievementSets()->core()->first()?->achievement_set_id)
->first();

if ($hashGameSubsetAttachment && in_array($hashGameSubsetAttachment->type, [AchievementSetType::Specialty, AchievementSetType::Exclusive])) {
/**
* At the root level:
* - Use the subset game's ID and achievements.
* - Use the core game's title and image.
* - Use the subset game's RP, if present.
*/
$richPresencePatch = $hashGame->RichPresencePatch ?: $richPresencePatch;

return $this->buildPatchData(
$hashGame, // ... use the subset game for ID and achievements ...
$resolvedSets,
$user,
$flag,
$richPresencePatch,
$coreGame // ... use the core game for title and image ...
);
}

return $this->buildPatchData($coreGame, $resolvedSets, $user, $flag, $richPresencePatch);
}

/**
* @param Game $game The game to build root-level data for
* @param Collection<int, GameAchievementSet>|null $resolvedSets The sets to send to the client/emulator
* @param User|null $user The current user requesting the patch data (for player count calculations)
* @param AchievementFlag|null $flag Optional flag to filter the achievements by (eg: only official achievements)
* @param string|null $richPresencePatch The RP patch code that the client should use
* @param Game|null $titleGame Optional game to use for title and image (for specialty/exclusive sets)
*/
private function buildPatchData(
Game $game,
?Collection $resolvedSets,
?User $user,
?AchievementFlag $flag,
?string $richPresencePatch = null,
?Game $titleGame = null
): array {
$gamePlayerCount = $this->calculateGamePlayerCount($game, $user);

$coreAchievementSet = GameAchievementSet::where('game_id', $game->id)
->core()
->with('achievementSet.achievements.developer')
->first();

// Don't fetch the games in the loop if we have sets. Grab them all in a single query.
$sets = [];
if ($resolvedSets?->isNotEmpty()) {
$coreGameIds = $resolvedSets->pluck('core_game_id')->unique();
$achievementSetIds = $resolvedSets->pluck('achievement_set_id')->unique();

// Preload all games.
$games = Game::whereIn('ID', $coreGameIds)->get()->keyBy('ID');

// Preload all GameAchievementSet entities we'll need.
$gameAchievementSets = GameAchievementSet::where(function ($query) use ($game, $achievementSetIds) {
$query->where('game_id', $game->id)->whereIn('achievement_set_id', $achievementSetIds);
})->orWhere(function ($query) use ($resolvedSets) {
$query
->whereIn('game_id', $resolvedSets->pluck('game_id'))
->whereIn('achievement_set_id', $resolvedSets->pluck('achievement_set_id'));
})->get();

foreach ($resolvedSets as $resolvedSet) {
// We don't want to include sets in the list that are duplicative
// with the root-level data in the response (for achievements & leaderboards).
if ($resolvedSet->game_id === $game->id && $resolvedSet->type === AchievementSetType::Core) {
continue;
}

// For specialty/exclusive sets, instead of looking up how this game's
// achievement set is attached, look up how the resolved set's achievement
// set is attached to its parent.
$setAttachment = $gameAchievementSets->first(function ($attachment) use ($resolvedSet) {
return
$attachment->game_id === $resolvedSet->game_id
&& $attachment->achievement_set_id === $resolvedSet->achievement_set_id
;
});

// Get the achievement set for the current game.
$gameAchievementSet = $gameAchievementSets->first(function ($attachment) use ($game, $resolvedSet) {
return
$attachment->game_id === $game->id
&& $attachment->achievement_set_id === $resolvedSet->achievement_set_id
;
});

// Skip if this is a specialty/exclusive set that we're directly loading a hash for.
if (
$setAttachment
&& in_array($setAttachment->type, [AchievementSetType::Specialty, AchievementSetType::Exclusive])
&& $gameAchievementSet !== null
) {
continue;
}

// Get the achievements for this set. If there are no published
// achievements, we won't bother sending the set to the client.
$achievements = $this->buildAchievementsData($resolvedSet, $gamePlayerCount, $flag);
if (empty($achievements)) {
continue;
}

$setGame = $games[$resolvedSet->core_game_id];
$sets[] = [
'GameAchievementSetID' => $resolvedSet->id,
'SetTitle' => $resolvedSet->title,
'Type' => $resolvedSet->type->value,
'ImageIcon' => $setGame->ImageIcon,
'ImageIconURL' => media_asset($setGame->ImageIcon),
'Achievements' => $achievements,
'Leaderboards' => $this->buildLeaderboardsData($setGame),
];
}
}

return [
'Success' => true,
'PatchData' => [
...$this->buildBaseGameData($game, $richPresencePatch, $titleGame),
'Achievements' => $coreAchievementSet
? $this->buildAchievementsData($coreAchievementSet, $gamePlayerCount, $flag)
: [],
'Leaderboards' => $this->buildLeaderboardsData($game),
...(!empty($sets) ? ['Sets' => $sets] : []),
],
];
}

/**
* Builds achievement information needed by emulators.
*
* @param GameAchievementSet $gameAchievementSet The achievement set to build achievement data for
* @param int $gamePlayerCount The total number of players (minimum of 1 to prevent division by zero)
* @param AchievementFlag|null $flag Optional flag to filter the achievements by (eg: only official achievements)
*/
private function buildAchievementsData(
GameAchievementSet $gameAchievementSet,
int $gamePlayerCount,
?AchievementFlag $flag,
): array {
/** @var Collection<int, Achievement> $achievements */
$achievements = $gameAchievementSet->achievementSet
->achievements()
->with('developer')
->orderBy('DisplayOrder') // explicit display order
->orderBy('ID') // tiebreaker on creation sequence
->get();

if ($flag) {
$achievements = $achievements->where('Flags', '=', $flag->value);
}

$achievementsData = [];

foreach ($achievements as $achievement) {
// If an achievement has an invalid flag, skip it.
if (!AchievementFlag::tryFrom($achievement->Flags)) {
continue;
}

// Calculate rarity assuming it will be used when the player unlocks the achievement,
// which implies they haven't already unlocked it.
$rarity = min(100.0, round((float) ($achievement->unlocks_total + 1) * 100 / $gamePlayerCount, 2));
$rarityHardcore = min(100.0, round((float) ($achievement->unlocks_hardcore_total + 1) * 100 / $gamePlayerCount, 2));

$achievementsData[] = [
'ID' => $achievement->id,
'MemAddr' => $achievement->MemAddr,
'Title' => $achievement->title,
'Description' => $achievement->description,
'Points' => $achievement->points,
'Author' => $achievement->developer->display_name ?? '',
'Modified' => $achievement->DateModified->unix(),
'Created' => $achievement->DateCreated->unix(),
'BadgeName' => $achievement->BadgeName,
'Flags' => $achievement->Flags,
'Type' => $achievement->type,
'Rarity' => $rarity,
'RarityHardcore' => $rarityHardcore,
'BadgeURL' => $achievement->badge_unlocked_url,
'BadgeLockedURL' => $achievement->badge_locked_url,
];
}

return $achievementsData;
}

/**
* Builds the basic game information needed by emulators.
*/
private function buildBaseGameData(Game $game, ?string $richPresencePatch, ?Game $titleGame): array
{
// If a title game is provided, use its title and image.
$titleGame = $titleGame ?? $game;

return [
'ID' => $game->id,
'Title' => $titleGame->title,
'ImageIcon' => $titleGame->ImageIcon,
'RichPresencePatch' => $richPresencePatch ?? $game->RichPresencePatch,
'ConsoleID' => $game->ConsoleID,
'ImageIconURL' => media_asset($titleGame->ImageIcon),
];
}

/**
* Builds leaderboard information needed by emulators.
*/
private function buildLeaderboardsData(Game $game): array
{
$leaderboardsData = [];

// TODO detach leaderboards from games
$leaderboards = $game->leaderboards()
->orderBy('DisplayOrder') // explicit display order
->orderBy('ID') // tiebreaker on creation sequence
->get();

foreach ($leaderboards as $leaderboard) {
$leaderboardsData[] = [
'ID' => $leaderboard->id,
'Mem' => $leaderboard->Mem,
'Format' => $leaderboard->Format,
'LowerIsBetter' => $leaderboard->LowerIsBetter,
'Title' => $leaderboard->title,
'Description' => $leaderboard->Description,
'Hidden' => ($leaderboard->DisplayOrder < 0),
];
}

return $leaderboardsData;
}

/**
* Calculates the total number of players for the game, which ultimately gets used in
* achievement rarity calculations.
*
* This method adds 1 to the total if the requesting user hasn't played the game yet,
* which ensures accurate rarity predictions for when they unlock achievements.
*
* @param Game $game The game to calculate player count for
* @param User|null $user The current user requesting the data
*
* @return int The total number of players (minimum of 1 to prevent division by zero)
*/
private function calculateGamePlayerCount(Game $game, ?User $user): int
{
$gamePlayerCount = $game->players_total;

if ($user) {
$hasPlayerGame = PlayerGame::whereUserId($user->id)
->whereGameId($game->id)
->exists();

if (!$hasPlayerGame) {
$gamePlayerCount++;
}
}

return max(1, $gamePlayerCount);
}
}
18 changes: 18 additions & 0 deletions app/Connect/Actions/GetClientSupportLevelAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Connect\Actions;

use App\Enums\ClientSupportLevel;
use App\Platform\Services\UserAgentService;

class GetClientSupportLevelAction
{
public function execute(string $userAgent): ClientSupportLevel
{
$userAgentService = new UserAgentService();

return $userAgentService->getSupportLevel($userAgent);
}
}
Loading

0 comments on commit 6fc0c05

Please sign in to comment.