Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Comment thread
Jamiras marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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()
Expand All @@ -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 */
Expand Down
2 changes: 1 addition & 1 deletion app/Helpers/render/game.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<x-game-title :rawTitle="$rawTitle" />', ['rawTitle' => $subset]);
}
Expand Down
73 changes: 7 additions & 66 deletions app/Models/Game.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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',
];
Expand Down Expand Up @@ -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.
Expand All @@ -733,12 +678,7 @@ 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->parentGameId !== null;
return $this->parent_game_id !== null;
Comment thread
Jamiras marked this conversation as resolved.
}

public function getCanHaveBeatenTypes(): bool
Expand Down Expand Up @@ -837,11 +777,12 @@ public function achievementSetClaims(): HasMany
return $this->hasMany(AchievementSetClaim::class, 'game_id');
}

public function parentGame(): ?Game
/**
* @return BelongsTo<Game, $this>
*/
public function parentGame(): BelongsTo
{
return once(function (): ?Game {
return $this->parentGameId ? Game::find($this->parentGameId) : null;
});
return $this->belongsTo(Game::class, 'parent_game_id');
}

/**
Expand Down
20 changes: 20 additions & 0 deletions app/Models/GameAchievementSet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<int, int>
*/
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
Expand Down
41 changes: 24 additions & 17 deletions app/Observers/GameAchievementSetObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
84 changes: 84 additions & 0 deletions app/Observers/GameObserver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace App\Observers;

use App\Models\Game;
use App\Platform\Actions\SyncGameParentGameIdAction;

class GameObserver
{
public function created(Game $game): void
{
$this->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));
}
}
}
9 changes: 6 additions & 3 deletions app/Platform/Actions/AssociateAchievementSetToGameAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading