Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(multiset): send sets to emulators #2857

Merged
merged 31 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a801b78
refactor(AchievementFlag): use a backed enum
wescopeland Nov 12, 2024
76b0af4
fix: game page flag
wescopeland Nov 12, 2024
cb43c1a
feat(multiset): add action to resolve sets for emulators
wescopeland Nov 13, 2024
b9c13d7
Merge branch 'master' into multiset-resolve-sets-action
wescopeland Nov 13, 2024
e26484c
ci: fallback install
wescopeland Nov 13, 2024
f0c5dbd
Merge branch 'multiset-resolve-sets-action' of https://github.com/wes…
wescopeland Nov 13, 2024
f241d39
chore: revert ci change
wescopeland Nov 13, 2024
d550f43
feat: include core game ids
wescopeland Nov 13, 2024
40cc59a
chore: clarify
wescopeland Nov 13, 2024
c2d9446
feat(multiset): send sets to emulators
wescopeland Nov 17, 2024
216e613
Merge branch 'master' into multiset-resolve-sets-action
wescopeland Nov 17, 2024
7b598ab
Merge branch 'multiset-resolve-sets-action' into multiset-patch-data
wescopeland Nov 17, 2024
0e17857
Merge branch 'master' into multiset-resolve-sets-action
wescopeland Nov 20, 2024
1ae1571
Merge branch 'multiset-resolve-sets-action' into multiset-patch-data
wescopeland Nov 20, 2024
7279a62
Merge branch 'master' into multiset-patch-data
wescopeland Nov 24, 2024
c939cb8
refactor: use an inject action, change namespace
wescopeland Nov 24, 2024
75f2b68
test: add coverage
wescopeland Nov 24, 2024
472ff2d
chore: remove todo
wescopeland Nov 24, 2024
c0daa17
fix: address pr feedback
wescopeland Nov 29, 2024
d885e96
Merge branch 'master' into multiset-patch-data
wescopeland Nov 29, 2024
8919e5c
fix: use correct rp
wescopeland Dec 1, 2024
0eed34b
fix: adjust root data
wescopeland Dec 1, 2024
081ad25
test: more edge case coverage
wescopeland Dec 1, 2024
7f162a9
Merge branch 'master' into multiset-patch-data
wescopeland Dec 1, 2024
3b904a4
Merge branch 'master' into multiset-patch-data
wescopeland Dec 2, 2024
913df3c
fix: address pr feedback
wescopeland Dec 2, 2024
65c51a0
Merge branch 'master' into multiset-patch-data
wescopeland Dec 2, 2024
a477f68
fix: address pr feedback
wescopeland Dec 3, 2024
53d8ef1
Merge branch 'master' into multiset-patch-data
wescopeland Dec 15, 2024
a85812a
Merge branch 'master' into multiset-patch-data
wescopeland Dec 18, 2024
23729ed
Merge branch 'master' into multiset-patch-data
wescopeland Dec 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Jamiras marked this conversation as resolved.
Show resolved Hide resolved
'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