From f9af50e044b826cdb3d9a4f5f8cfa98912bc8222 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 14 May 2026 20:05:16 -0400 Subject: [PATCH 1/3] perf: denormalize games.parent_game_id --- .../AchievementSetsRelationManager.php | 28 ++-- app/Helpers/render/game.php | 2 +- app/Models/Game.php | 71 ++-------- app/Models/GameAchievementSet.php | 20 +++ app/Observers/GameAchievementSetObserver.php | 41 +++--- app/Observers/GameObserver.php | 84 ++++++++++++ .../AssociateAchievementSetToGameAction.php | 9 +- .../Actions/SyncGameParentGameIdAction.php | 80 +++++++++++ .../UpdateAchievementMetricsAction.php | 4 +- ...oreAchievementSetFromLegacyFlagsAction.php | 9 +- app/Providers/EventServiceProvider.php | 3 + app/Support/Cache/CacheKey.php | 5 - .../Cache/GameParentCacheInvalidator.php | 38 ------ ...0000_add_parent_game_id_to_games_table.php | 93 +++++++++++++ public/API/API_GetGameExtended.php | 2 +- public/API/API_GetGameInfoAndUserProgress.php | 2 +- .../SyncGameParentGameIdActionTest.php | 126 ++++++++++++++++++ 17 files changed, 478 insertions(+), 139 deletions(-) create mode 100644 app/Observers/GameObserver.php create mode 100644 app/Platform/Actions/SyncGameParentGameIdAction.php delete mode 100644 app/Support/Cache/GameParentCacheInvalidator.php create mode 100644 database/migrations/2026_05_14_000000_add_parent_game_id_to_games_table.php create mode 100644 tests/Feature/Platform/Actions/SyncGameParentGameIdActionTest.php diff --git a/app/Filament/Resources/GameResource/RelationManagers/AchievementSetsRelationManager.php b/app/Filament/Resources/GameResource/RelationManagers/AchievementSetsRelationManager.php index 4759d68f63..4d829d2409 100644 --- a/app/Filament/Resources/GameResource/RelationManagers/AchievementSetsRelationManager.php +++ b/app/Filament/Resources/GameResource/RelationManagers/AchievementSetsRelationManager.php @@ -12,8 +12,8 @@ use App\Platform\Actions\AssociateAchievementSetToGameAction; use App\Platform\Actions\LogGameAchievementSetActivityAction; use App\Platform\Actions\ResolveBackingGameForAchievementSetAction; +use App\Platform\Actions\SyncGameParentGameIdAction; use App\Platform\Enums\AchievementSetType; -use App\Support\Cache\GameParentCacheInvalidator; use BackedEnum; use Filament\Actions; use Filament\Actions\DetachAction; @@ -373,8 +373,11 @@ function ($attribute, $value, $fail) { ); // updateExistingPivot is a pivot-level write and skips the - // GameAchievementSet observer, so invalidate explicitly. - GameParentCacheInvalidator::invalidate($currentGame->id, $record->id); + // GameAchievementSet observer, so sync explicitly. + $syncAction = new SyncGameParentGameIdAction(); + foreach (GameAchievementSet::gameIdsAffectedBy($currentGame->id, $record->id) as $affectedGameId) { + $syncAction->execute($affectedGameId); + } $gameAchievementSet = GameAchievementSet::where('game_id', $currentGame->id) ->where('achievement_set_id', $record->id) @@ -419,8 +422,11 @@ function ($attribute, $value, $fail) { ->hidden(fn ($record) => $record->type === AchievementSetType::Core->value) ->after(function (AchievementSet $record) use ($game) { // Detach is a pivot-level write and skips the GameAchievementSet - // observer, so invalidate the parent_id cache for both sides. - GameParentCacheInvalidator::invalidate($game->id, $record->id); + // observer, so sync the denormalized parent_game_id for both sides. + $syncAction = new SyncGameParentGameIdAction(); + foreach (GameAchievementSet::gameIdsAffectedBy($game->id, $record->id) as $affectedGameId) { + $syncAction->execute($affectedGameId); + } }), ]) ->toolbarActions([ @@ -451,7 +457,7 @@ private function toggleMultisetTypes(Game $game): void ? self::WILL_BE_TO_FINAL_TYPE_MAP : array_flip(self::WILL_BE_TO_FINAL_TYPE_MAP); - // Capture which sets we touched so we can invalidate the parent_id cache + // Capture which sets we touched so we can sync the denormalized parent_game_id // for both this game and every other game sharing those sets. The bulk // update below uses query-builder ->update which skips model events. $affectedSetIds = $game->gameAchievementSets() @@ -464,8 +470,14 @@ private function toggleMultisetTypes(Game $game): void ->update(['type' => $to, 'updated_at' => now()]); } - foreach ($affectedSetIds as $affectedSetId) { - GameParentCacheInvalidator::invalidate($game->id, (int) $affectedSetId); + $gameIdsToSync = GameAchievementSet::query() + ->whereIn('achievement_set_id', $affectedSetIds) + ->pluck('game_id') + ->push($game->id) + ->unique(); + $syncAction = new SyncGameParentGameIdAction(); + foreach ($gameIdsToSync as $gameId) { + $syncAction->execute((int) $gameId); } /** @var User $user */ diff --git a/app/Helpers/render/game.php b/app/Helpers/render/game.php index 155bca7671..feba53160e 100644 --- a/app/Helpers/render/game.php +++ b/app/Helpers/render/game.php @@ -102,7 +102,7 @@ function renderGameBreadcrumb(array|int $data, bool $addLinkToLastCrumb = true): if (preg_match('/(.+)(\[Subset - .+\])/', $gameTitle, $matches)) { $mainTitle = trim($matches[1]); $subset = $matches[2]; - $mainID = Game::find($gameID)->parentGameId; + $mainID = Game::find($gameID)->parent_game_id; $subsetID = $gameID; $renderedSubset = Blade::render('', ['rawTitle' => $subset]); } diff --git a/app/Models/Game.php b/app/Models/Game.php index ab30a6c4dd..12a8e94f40 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -21,7 +21,6 @@ use App\Platform\Enums\GameSetType; use App\Platform\Enums\ReleasedAtGranularity; use App\Platform\Enums\ScreenshotType; -use App\Support\Cache\CacheKey; use App\Support\Database\Eloquent\BaseModel; use Database\Factories\GameFactory; use Fico7489\Laravel\Pivot\Traits\PivotEventTrait; @@ -38,7 +37,6 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Laravel\Scout\Searchable; @@ -109,6 +107,7 @@ class Game extends BaseModel implements HasMedia, HasPermalink, HasVersionedTrig 'comments_locked_at' => 'datetime', 'is_media_restricted' => 'boolean', 'last_achievement_update' => 'datetime', + 'parent_game_id' => 'integer', 'released_at_granularity' => ReleasedAtGranularity::class, 'released_at' => 'datetime', ]; @@ -660,60 +659,6 @@ public function getBannerAttribute(): PageBannerData ); } - public function getParentGameIdAttribute(): ?int - { - return once(function () { - // Null results have to be stored as 0 because Laravel's Cache::remember - // treats a cached null as a miss and would re-execute the resolver every call. - $cached = (int) Cache::remember( - CacheKey::buildGameParentIdCacheKey($this->id), - Carbon::now()->addHour(), - fn (): int => $this->resolveParentGameId() ?? 0, - ); - - return $cached === 0 ? null : $cached; - }); - } - - private function resolveParentGameId(): ?int - { - // Get this game's core achievement set(s). - $coreAchievementSets = GameAchievementSet::where('game_id', $this->id) - ->where('type', AchievementSetType::Core) - ->pluck('achievement_set_id'); - - if ($coreAchievementSets->isEmpty()) { - return null; - } - - // Check if another game uses any of this game's core achievement sets as a non-core type. - // This would indicate that the other game is the parent. - $nonCoreUsage = GameAchievementSet::whereIn('achievement_set_id', $coreAchievementSets) - ->where('game_id', '!=', $this->id) - ->where('type', '!=', AchievementSetType::Core) - ->orderBy('created_at') // if more than one parent exists, take the first associated - ->select('game_id') - ->first(); - - if ($nonCoreUsage) { - return $nonCoreUsage->game_id; - } - - // If no mapping exists, but title includes "[Subset", try to find the parent by name - $index = strpos($this->title, '[Subset - '); - if ($index !== false) { - // Trim to ensure no leading/trailing spaces. - $baseSetTitle = trim(substr($this->title, 0, $index)); - - // Attempt to find a game with the base title and the same system ID. - return Game::where('title', $baseSetTitle) - ->where('system_id', $this->system_id) - ->value('id'); - } - - return null; - } - public function getHasActiveOrInReviewClaimsAttribute(): bool { // BuildsGameListQueries injects this as a virtual field. @@ -738,7 +683,7 @@ public function getIsSubsetGameAttribute(): bool return true; } - return $this->parentGameId !== null; + return $this->parent_game_id !== null; } public function getCanHaveBeatenTypes(): bool @@ -839,9 +784,7 @@ public function achievementSetClaims(): HasMany public function parentGame(): ?Game { - return once(function (): ?Game { - return $this->parentGameId ? Game::find($this->parentGameId) : null; - }); + return $this->parent; } /** @@ -951,6 +894,14 @@ public function visibleModificationsComments(?User $user = null): HasMany return $this->modificationsComments()->visibleTo($currentUser); } + /** + * @return BelongsTo + */ + public function parent(): BelongsTo + { + return $this->belongsTo(Game::class, 'parent_game_id'); + } + /** * @return BelongsTo */ diff --git a/app/Models/GameAchievementSet.php b/app/Models/GameAchievementSet.php index 5849b11737..cf255e6e23 100644 --- a/app/Models/GameAchievementSet.php +++ b/app/Models/GameAchievementSet.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Collection; class GameAchievementSet extends BaseModel { @@ -35,6 +36,25 @@ protected static function newFactory(): GameAchievementSetFactory return GameAchievementSetFactory::new(); } + /** + * Returns every game whose denormalized parent_game_id may be affected by a + * mutation to the given game_id+achievement_set_id pair. + * + * @return Collection + */ + public static function gameIdsAffectedBy(?int $gameId, ?int $achievementSetId): Collection + { + $gameIds = $achievementSetId === null + ? collect() + : self::query()->where('achievement_set_id', $achievementSetId)->pluck('game_id'); + + if ($gameId !== null) { + $gameIds->push($gameId); + } + + return $gameIds->unique()->values(); + } + // == accessors // == mutators diff --git a/app/Observers/GameAchievementSetObserver.php b/app/Observers/GameAchievementSetObserver.php index d56daa7fad..55df17c130 100644 --- a/app/Observers/GameAchievementSetObserver.php +++ b/app/Observers/GameAchievementSetObserver.php @@ -5,41 +5,48 @@ namespace App\Observers; use App\Models\GameAchievementSet; -use App\Support\Cache\GameParentCacheInvalidator; - -// TODO this was added in a hotfix - long-term we should denormalize this value!! +use App\Platform\Actions\SyncGameParentGameIdAction; /** - * Best-effort safety net for Game::parentGameId cache invalidation. Pivot + * Best-effort safety net for keeping games.parent_game_id synchronized. Pivot * operations like attach()/updateExistingPivot()/detach() don't reliably fire - * Eloquent model events, so explicit invalidation at known mutation sites is - * the primary mechanism; this observer just covers direct create/save/delete + * Eloquent model events, so explicit synchronization at known mutation sites is + * the primary mechanism. This observer just covers direct create/save/delete * paths against GameAchievementSet. */ class GameAchievementSetObserver { public function created(GameAchievementSet $model): void { - GameParentCacheInvalidator::invalidate($model->game_id, $model->achievement_set_id); + $this->syncAffected($model->game_id, $model->achievement_set_id); } public function updated(GameAchievementSet $model): void { - // If achievement_set_id or game_id changed, the original side could still hold a stale - // result for the *other* game in the old relationship, so flush both old and new. - $originalGameId = $model->getOriginal('game_id'); - $originalAchievementSetId = $model->getOriginal('achievement_set_id'); + $this->syncAffected($model->game_id, $model->achievement_set_id); - GameParentCacheInvalidator::invalidate( - $originalGameId !== null ? (int) $originalGameId : null, - $originalAchievementSetId !== null ? (int) $originalAchievementSetId : null, - ); + if ($model->wasChanged(['game_id', 'achievement_set_id'])) { + $originalGameId = $model->getOriginal('game_id'); + $originalAchievementSetId = $model->getOriginal('achievement_set_id'); - GameParentCacheInvalidator::invalidate($model->game_id, $model->achievement_set_id); + $this->syncAffected( + $originalGameId !== null ? (int) $originalGameId : null, + $originalAchievementSetId !== null ? (int) $originalAchievementSetId : null, + ); + } } public function deleted(GameAchievementSet $model): void { - GameParentCacheInvalidator::invalidate($model->game_id, $model->achievement_set_id); + $this->syncAffected($model->game_id, $model->achievement_set_id); + } + + private function syncAffected(?int $gameId, ?int $achievementSetId): void + { + $syncAction = new SyncGameParentGameIdAction(); + + foreach (GameAchievementSet::gameIdsAffectedBy($gameId, $achievementSetId) as $affectedGameId) { + $syncAction->execute($affectedGameId); + } } } diff --git a/app/Observers/GameObserver.php b/app/Observers/GameObserver.php new file mode 100644 index 0000000000..4c85b2353b --- /dev/null +++ b/app/Observers/GameObserver.php @@ -0,0 +1,84 @@ +syncTitleFallbackSubsetsOf($game); // match against existing subset-titled games if this is a new parent + } + + public function updated(Game $game): void + { + if (!$game->wasChanged(['title', 'system_id'])) { + return; + } + + (new SyncGameParentGameIdAction())->execute($game); + + $previousSystemId = $game->getOriginal('system_id'); + $this->syncTitleFallbackSubsetsOf( + $game, + previousTitle: $game->getOriginal('title'), + previousSystemId: $previousSystemId !== null ? (int) $previousSystemId : null, + ); + } + + public function deleted(Game $game): void + { + $this->syncTitleFallbackSubsetsOf($game); + } + + public function restored(Game $game): void + { + (new SyncGameParentGameIdAction())->execute($game); + $this->syncTitleFallbackSubsetsOf($game); + } + + /** + * Re-sync any subset games whose parent_game_id was inferred from a "[Subset - X]" + * title pointing at this game (current and/or previous title/system pairing). + */ + private function syncTitleFallbackSubsetsOf( + Game $parentGame, + ?string $previousTitle = null, + ?int $previousSystemId = null, + ): void { + $candidates = []; + + if (filled($parentGame->title) && $parentGame->system_id !== null) { + $candidates[] = [$parentGame->title, $parentGame->system_id]; + } + + if ( + filled($previousTitle) + && $previousSystemId !== null + && ($previousTitle !== $parentGame->title || $previousSystemId !== $parentGame->system_id) + ) { + $candidates[] = [$previousTitle, $previousSystemId]; + } + + if ($candidates === []) { + return; + } + + $syncAction = new SyncGameParentGameIdAction(); + + foreach ($candidates as [$title, $systemId]) { + $escapedTitle = addcslashes((string) $title, '\\%_'); + + Game::withTrashed() + ->where('id', '!=', $parentGame->id) + ->where('system_id', $systemId) + ->where('title', 'like', $escapedTitle . ' [Subset - %') + ->pluck('id') + ->each(fn (int $gameId) => $syncAction->execute($gameId)); + } + } +} diff --git a/app/Platform/Actions/AssociateAchievementSetToGameAction.php b/app/Platform/Actions/AssociateAchievementSetToGameAction.php index c93cb92a9b..688b5ce960 100644 --- a/app/Platform/Actions/AssociateAchievementSetToGameAction.php +++ b/app/Platform/Actions/AssociateAchievementSetToGameAction.php @@ -6,8 +6,8 @@ use App\Models\AchievementSet; use App\Models\Game; +use App\Models\GameAchievementSet; use App\Platform\Enums\AchievementSetType; -use App\Support\Cache\GameParentCacheInvalidator; use InvalidArgumentException; /** @@ -65,8 +65,11 @@ private function associateAchievementSet( ]); // The attach is a pivot insert and doesn't fire the GameAchievementSet - // observer, so we have to invalidate explicitly here. - GameParentCacheInvalidator::invalidate($game->id, $achievementSet->id); + // observer, so we have to sync explicitly here. + $syncAction = new SyncGameParentGameIdAction(); + foreach (GameAchievementSet::gameIdsAffectedBy($game->id, $achievementSet->id) as $affectedGameId) { + $syncAction->execute($affectedGameId); + } } private function isSpecialtyType(AchievementSetType $type): bool diff --git a/app/Platform/Actions/SyncGameParentGameIdAction.php b/app/Platform/Actions/SyncGameParentGameIdAction.php new file mode 100644 index 0000000000..c825c7f06d --- /dev/null +++ b/app/Platform/Actions/SyncGameParentGameIdAction.php @@ -0,0 +1,80 @@ +select(['id', 'title', 'system_id']) + ->find($game); + + if ($game === null) { + return null; + } + } + + $parentGameId = $this->resolveParentGameId($game); + + DB::table('games') + ->where('id', $game->id) + ->update(['parent_game_id' => $parentGameId]); + + $game->setAttribute('parent_game_id', $parentGameId); + $game->syncOriginalAttribute('parent_game_id'); + + return $parentGameId; + } + + private function resolveParentGameId(Game $game): ?int + { + $coreAchievementSetIds = GameAchievementSet::query() + ->where('game_id', $game->id) + ->where('type', AchievementSetType::Core) + ->pluck('achievement_set_id'); + + if ($coreAchievementSetIds->isEmpty()) { + return null; + } + + $parentGameId = GameAchievementSet::query() + ->whereIn('achievement_set_id', $coreAchievementSetIds) + ->where('game_id', '!=', $game->id) + ->where('type', '!=', AchievementSetType::Core) + ->orderBy('created_at') + ->orderBy('id') + ->value('game_id'); + + if ($parentGameId !== null) { + return (int) $parentGameId; + } + + $index = mb_strpos($game->title ?? '', '[Subset - '); + if ($index === false) { + return null; + } + + $baseSetTitle = trim(mb_substr($game->title ?? '', 0, $index)); + if ($baseSetTitle === '') { + return null; + } + + $parentGameId = Game::query() + ->where('title', $baseSetTitle) + ->where('system_id', $game->system_id) + ->value('id'); + + return $parentGameId !== null ? (int) $parentGameId : null; + } +} diff --git a/app/Platform/Actions/UpdateAchievementMetricsAction.php b/app/Platform/Actions/UpdateAchievementMetricsAction.php index 4e0161fa90..93b3a29962 100644 --- a/app/Platform/Actions/UpdateAchievementMetricsAction.php +++ b/app/Platform/Actions/UpdateAchievementMetricsAction.php @@ -42,8 +42,8 @@ public function update(Game $game, Collection $achievements): void // if game has a parent game, fetch the parent game's players metrics $retroRatioPlayerCount = $playersHardcore; - if ($game->parentGameId) { - $retroRatioPlayerCount = Game::find($game->parentGameId)->players_hardcore ?? 0; + if ($game->parent_game_id) { + $retroRatioPlayerCount = Game::find($game->parent_game_id)->players_hardcore ?? 0; } // Get both total and hardcore counts in a single query. diff --git a/app/Platform/Actions/UpsertGameCoreAchievementSetFromLegacyFlagsAction.php b/app/Platform/Actions/UpsertGameCoreAchievementSetFromLegacyFlagsAction.php index 9dc9350c2d..6fd2a9e56a 100644 --- a/app/Platform/Actions/UpsertGameCoreAchievementSetFromLegacyFlagsAction.php +++ b/app/Platform/Actions/UpsertGameCoreAchievementSetFromLegacyFlagsAction.php @@ -7,8 +7,8 @@ use App\Models\AchievementSet; use App\Models\AchievementSetAchievement; use App\Models\Game; +use App\Models\GameAchievementSet; use App\Platform\Enums\AchievementSetType; -use App\Support\Cache\GameParentCacheInvalidator; use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -37,8 +37,11 @@ public function execute(Game $game): void ]); // The attach is a pivot insert and doesn't fire the GameAchievementSet - // observer, so we have to invalidate explicitly here. - GameParentCacheInvalidator::invalidate($game->id, $newAchievementSet->id); + // observer, so we have to sync explicitly here. + $syncAction = new SyncGameParentGameIdAction(); + foreach (GameAchievementSet::gameIdsAffectedBy($game->id, $newAchievementSet->id) as $affectedGameId) { + $syncAction->execute($affectedGameId); + } } }); } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 25a454ccad..fcea4f8fde 100755 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -7,6 +7,7 @@ use App\Events\UserDeleted; use App\Listeners\SendUserRegistrationNotification; use App\Models\EventAchievement; +use App\Models\Game; use App\Models\GameAchievementSet; use App\Models\GameSet; use App\Models\GameSetLink; @@ -14,6 +15,7 @@ use App\Models\User; use App\Observers\EventAchievementObserver; use App\Observers\GameAchievementSetObserver; +use App\Observers\GameObserver; use App\Observers\GameSetLinkObserver; use App\Observers\GameSetObserver; use App\Observers\LeaderboardEntryObserver; @@ -80,6 +82,7 @@ public function boot(): void EventAchievement::observe(EventAchievementObserver::class); + Game::observe(GameObserver::class); GameAchievementSet::observe(GameAchievementSetObserver::class); GameSetLink::observe(GameSetLinkObserver::class); GameSet::observe(GameSetObserver::class); diff --git a/app/Support/Cache/CacheKey.php b/app/Support/Cache/CacheKey.php index b3a6897f75..3e108f7716 100644 --- a/app/Support/Cache/CacheKey.php +++ b/app/Support/Cache/CacheKey.php @@ -13,11 +13,6 @@ public static function buildGameCardDataCacheKey(int $gameId): string return self::buildNormalizedCacheKey("game", $gameId, "card-data"); } - public static function buildGameParentIdCacheKey(int $gameId): string - { - return self::buildNormalizedCacheKey("game", $gameId, "parent-id"); - } - public static function buildGameSetBreadcrumbsCacheKey(int $gameSetId): string { return self::buildNormalizedCacheKey("game-set", $gameSetId, "breadcrumbs2"); diff --git a/app/Support/Cache/GameParentCacheInvalidator.php b/app/Support/Cache/GameParentCacheInvalidator.php deleted file mode 100644 index bd2a48ec8c..0000000000 --- a/app/Support/Cache/GameParentCacheInvalidator.php +++ /dev/null @@ -1,38 +0,0 @@ -where('achievement_set_id', $achievementSetId) - ->pluck('game_id'); - - foreach ($sharedGameIds as $sharedGameId) { - if ($sharedGameId === $gameId) { - continue; - } - - Cache::forget(CacheKey::buildGameParentIdCacheKey((int) $sharedGameId)); - } - } -} diff --git a/database/migrations/2026_05_14_000000_add_parent_game_id_to_games_table.php b/database/migrations/2026_05_14_000000_add_parent_game_id_to_games_table.php new file mode 100644 index 0000000000..b4096f6c5f --- /dev/null +++ b/database/migrations/2026_05_14_000000_add_parent_game_id_to_games_table.php @@ -0,0 +1,93 @@ +foreignId('parent_game_id') + ->nullable() + ->after('system_id') + ->constrained('games') + ->nullOnDelete(); + }); + + if (DB::connection()->getDriverName() !== 'sqlite') { + $this->backfillFromAchievementSetLinks(); + $this->backfillFromSubsetTitles(); + } + } + + public function down(): void + { + Schema::table('games', function (Blueprint $table) { + $table->dropForeign(['parent_game_id']); + $table->dropColumn('parent_game_id'); + }); + } + + /** + * Pass 1: derive parent_game_id from achievement set links. For each game's + * core set, the earliest other game that uses that set as a non-core type + * is the parent. Tiebreaker matches the runtime resolver: created_at, id. + */ + private function backfillFromAchievementSetLinks(): void + { + DB::statement(<<<'SQL' + UPDATE games g + INNER JOIN ( + SELECT game_id, parent_id + FROM ( + SELECT + core.game_id, + parent_link.game_id AS parent_id, + ROW_NUMBER() OVER ( + PARTITION BY core.game_id + ORDER BY parent_link.created_at, parent_link.id + ) AS rn + FROM game_achievement_sets core + INNER JOIN game_achievement_sets parent_link + ON parent_link.achievement_set_id = core.achievement_set_id + AND parent_link.game_id != core.game_id + AND parent_link.type != 'core' + WHERE core.type = 'core' + ) ranked + WHERE rn = 1 + ) parents ON parents.game_id = g.id + SET g.parent_game_id = parents.parent_id + SQL); + } + + /** + * Pass 2: for any "[Subset - X]" titled game still unparented, look up the + * parent by base title + system_id. + */ + private function backfillFromSubsetTitles(): void + { + DB::statement(<<<'SQL' + UPDATE games subset + INNER JOIN games parent + ON parent.system_id = subset.system_id + AND parent.id != subset.id + AND parent.deleted_at IS NULL + AND subset.title LIKE CONCAT( + REPLACE(REPLACE(REPLACE(parent.title, '\\', '\\\\'), '%', '\\%'), '_', '\\_'), + ' [Subset - %' + ) + SET subset.parent_game_id = parent.id + WHERE subset.parent_game_id IS NULL + AND subset.title LIKE '% [Subset - %' + AND EXISTS ( + SELECT 1 FROM game_achievement_sets core_set + WHERE core_set.game_id = subset.id + AND core_set.type = 'core' + ) + SQL); + } +}; diff --git a/public/API/API_GetGameExtended.php b/public/API/API_GetGameExtended.php index b425fa1648..849b89eb06 100644 --- a/public/API/API_GetGameExtended.php +++ b/public/API/API_GetGameExtended.php @@ -150,7 +150,7 @@ $gameData, [ 'ConsoleName' => $game->system->name, - 'ParentGameID' => $game->parentGameId, + 'ParentGameID' => $game->parent_game_id, 'NumDistinctPlayers' => $game->players_total, 'NumAchievements' => count($gameAchievements), 'Achievements' => $gameListAchievements, diff --git a/public/API/API_GetGameInfoAndUserProgress.php b/public/API/API_GetGameInfoAndUserProgress.php index 87df498815..2f53e18cb5 100644 --- a/public/API/API_GetGameInfoAndUserProgress.php +++ b/public/API/API_GetGameInfoAndUserProgress.php @@ -87,7 +87,7 @@ 'Title' => $game->title, 'ConsoleID' => $game->system->id, 'ConsoleName' => $game->system->name, - 'ParentGameID' => $game->parentGameId, + 'ParentGameID' => $game->parent_game_id, 'NumDistinctPlayers' => $game->players_total, 'NumDistinctPlayersCasual' => $game->players_total, 'NumDistinctPlayersHardcore' => $game->players_total, diff --git a/tests/Feature/Platform/Actions/SyncGameParentGameIdActionTest.php b/tests/Feature/Platform/Actions/SyncGameParentGameIdActionTest.php new file mode 100644 index 0000000000..82630b17c1 --- /dev/null +++ b/tests/Feature/Platform/Actions/SyncGameParentGameIdActionTest.php @@ -0,0 +1,126 @@ +system = System::factory()->create(); +}); + +it('stores parent_game_id from achievement set links', function () { + // ARRANGE + $achievementSet = AchievementSet::factory()->create(); + $parentGame = Game::factory()->create(['system_id' => $this->system->id]); + $subsetGame = Game::factory()->create(['system_id' => $this->system->id]); + + // ACT + GameAchievementSet::factory()->create([ + 'game_id' => $subsetGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + + GameAchievementSet::factory()->create([ + 'game_id' => $parentGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Bonus, + ]); + + // ASSERT + expect($subsetGame->refresh()->parent_game_id)->toEqual($parentGame->id); + expect($parentGame->refresh()->parent_game_id)->toBeNull(); +}); + +it('clears parent_game_id when the achievement set link is removed', function () { + // ARRANGE + $achievementSet = AchievementSet::factory()->create(); + $parentGame = Game::factory()->create(['system_id' => $this->system->id]); + $subsetGame = Game::factory()->create(['system_id' => $this->system->id]); + + GameAchievementSet::factory()->create([ + 'game_id' => $subsetGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + + $parentLink = GameAchievementSet::factory()->create([ + 'game_id' => $parentGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Bonus, + ]); + + expect($subsetGame->refresh()->parent_game_id)->toEqual($parentGame->id); + + // ACT + $parentLink->delete(); + + // ASSERT + expect($subsetGame->refresh()->parent_game_id)->toBeNull(); +}); + +it('keeps the earliest parent when multiple parents use the same set', function () { + // ARRANGE + $achievementSet = AchievementSet::factory()->create(); + $subsetGame = Game::factory()->create(['system_id' => $this->system->id]); + $secondParentGame = Game::factory()->create(['system_id' => $this->system->id]); + $firstParentGame = Game::factory()->create(['system_id' => $this->system->id]); + + GameAchievementSet::factory()->create([ + 'game_id' => $subsetGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Core, + ]); + + // ACT + GameAchievementSet::factory()->create([ + 'game_id' => $secondParentGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Bonus, + 'created_at' => now()->addMinute(), + ]); + + GameAchievementSet::factory()->create([ + 'game_id' => $firstParentGame->id, + 'achievement_set_id' => $achievementSet->id, + 'type' => AchievementSetType::Bonus, + 'created_at' => now(), + ]); + + // ASSERT + expect($subsetGame->refresh()->parent_game_id)->toEqual($firstParentGame->id); +}); + +it('clears an existing title-fallback subset when its parent game is renamed', function () { + // ARRANGE + $parentGame = Game::factory()->create([ + 'title' => 'Mega Man 2', + 'system_id' => $this->system->id, + ]); + + $subsetGame = Game::factory()->create([ + 'title' => 'Mega Man 2 [Subset - Bonus]', + 'system_id' => $this->system->id, + ]); + + GameAchievementSet::factory()->create([ + 'game_id' => $subsetGame->id, + 'achievement_set_id' => AchievementSet::factory(), + 'type' => AchievementSetType::Core, + ]); + + expect($subsetGame->refresh()->parent_game_id)->toEqual($parentGame->id); + + // ACT + $parentGame->update(['title' => 'Mega Man II']); + + // ASSERT + expect($subsetGame->refresh()->parent_game_id)->toBeNull(); +}); From e8394f22748ff48664e0e48985f5a685e68dc703 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Tue, 19 May 2026 18:33:30 -0400 Subject: [PATCH 2/3] fix: address feedback --- app/Models/Game.php | 20 +++++-------------- .../UpdateAchievementMetricsAction.php | 5 +++-- .../Controllers/AchievementController.php | 2 +- .../Api/AchievementApiController.php | 2 +- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/app/Models/Game.php b/app/Models/Game.php index 12a8e94f40..e481889cab 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -678,11 +678,6 @@ public function getHasActiveOrInReviewClaimsAttribute(): bool public function getIsSubsetGameAttribute(): bool { - // See if we can short circut the queries to build parentGameId first. - if (str_contains($this->title, '[Subset')) { - return true; - } - return $this->parent_game_id !== null; } @@ -782,9 +777,12 @@ public function achievementSetClaims(): HasMany return $this->hasMany(AchievementSetClaim::class, 'game_id'); } - public function parentGame(): ?Game + /** + * @return BelongsTo + */ + public function parentGame(): BelongsTo { - return $this->parent; + return $this->belongsTo(Game::class, 'parent_game_id'); } /** @@ -894,14 +892,6 @@ public function visibleModificationsComments(?User $user = null): HasMany return $this->modificationsComments()->visibleTo($currentUser); } - /** - * @return BelongsTo - */ - public function parent(): BelongsTo - { - return $this->belongsTo(Game::class, 'parent_game_id'); - } - /** * @return BelongsTo */ diff --git a/app/Platform/Actions/UpdateAchievementMetricsAction.php b/app/Platform/Actions/UpdateAchievementMetricsAction.php index 2f112d40a9..4072eb550a 100644 --- a/app/Platform/Actions/UpdateAchievementMetricsAction.php +++ b/app/Platform/Actions/UpdateAchievementMetricsAction.php @@ -130,8 +130,9 @@ private function updateUsingUnlockCounts( $playersTotal = $game->players_total; $playersHardcore = $game->players_hardcore ?? 0; $retroRatioPlayerCount = $playersHardcore; - if ($game->parentGameId) { - $retroRatioPlayerCount = Game::find($game->parentGameId)->players_hardcore ?? 0; + $game->loadMissing('parentGame'); + if ($game->parentGame) { + $retroRatioPlayerCount = $game->parentGame->players_hardcore ?? 0; } $rankedPlayerCount = countRankedUsers(RankType::TruePoints); diff --git a/app/Platform/Controllers/AchievementController.php b/app/Platform/Controllers/AchievementController.php index 99185207d1..897296bb89 100644 --- a/app/Platform/Controllers/AchievementController.php +++ b/app/Platform/Controllers/AchievementController.php @@ -390,7 +390,7 @@ private function resolveSubsetContext(Achievement $achievement): array { $game = $achievement->game; - $backingGame = $game->parentGame(); + $backingGame = $game->parentGame; if (!$backingGame) { return [null, null]; } diff --git a/app/Platform/Controllers/Api/AchievementApiController.php b/app/Platform/Controllers/Api/AchievementApiController.php index 8462615ebf..0712f8eb01 100644 --- a/app/Platform/Controllers/Api/AchievementApiController.php +++ b/app/Platform/Controllers/Api/AchievementApiController.php @@ -26,7 +26,7 @@ public function update(QuickEditAchievementRequest $request, Achievement $achiev array_key_exists('type', $validated) && $validated['type'] !== null && AchievementType::isProgression($validated['type']) - && $achievement->game->parentGame() !== null + && $achievement->game->parentGame !== null ) { abort(422, 'Subset achievements cannot have progression or win_condition types.'); } From 47b7720e6e664e0a2f3b21254d87d4b0e78e0708 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Tue, 19 May 2026 19:13:31 -0400 Subject: [PATCH 3/3] chore: delete dead test case --- .../Controllers/GameControllerShowTest.php | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/tests/Feature/Platform/Controllers/GameControllerShowTest.php b/tests/Feature/Platform/Controllers/GameControllerShowTest.php index 88b33690f8..4e94246d61 100644 --- a/tests/Feature/Platform/Controllers/GameControllerShowTest.php +++ b/tests/Feature/Platform/Controllers/GameControllerShowTest.php @@ -3212,30 +3212,6 @@ function createLeaderboardWithEntries( ); }); - it('given this is a subset-by-label-only, does not include screenshot upload props', function () { - // ARRANGE - config()->set('feature.game_screenshot_uploads', true); - - $system = System::factory()->create(); - $game = createGameWithAchievements($system, 'Test Game [Subset - Bonus]'); - $user = User::factory()->create([ - 'points_hardcore' => 250, - 'email_verified_at' => now(), - 'created_at' => now()->subDays(45), - ]); - - // ACT - $response = actingAs($user)->get(route('game.show', ['game' => $game])); - - // ASSERT - $response->assertInertia(fn (Assert $page) => $page - ->where('can.createGameScreenshot', false) - ->missing('screenshotUploadStatuses') - ->missing('screenshotUploadPendingCount') - ->missing('screenshotUploadUserSubmissions') - ); - }); - it('given an unranked non-developer, does not include screenshot upload props', function () { // ARRANGE config()->set('feature.game_screenshot_uploads', true);