From 62e07c57d59e594d26ea224eff47c6bbd3dd45d4 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 11 Jun 2026 18:09:40 -0400 Subject: [PATCH 1/4] feat(manage/games): add a cap to the max number of similar games --- .../SimilarGamesCapExceededException.php | 17 +++++ .../GameResource/Pages/SimilarGames.php | 49 ++++++++++++--- .../Actions/LinkSimilarGamesAction.php | 63 +++++++++++++------ 3 files changed, 101 insertions(+), 28 deletions(-) create mode 100644 app/Exceptions/SimilarGamesCapExceededException.php diff --git a/app/Exceptions/SimilarGamesCapExceededException.php b/app/Exceptions/SimilarGamesCapExceededException.php new file mode 100644 index 0000000000..9ee38dfcfd --- /dev/null +++ b/app/Exceptions/SimilarGamesCapExceededException.php @@ -0,0 +1,17 @@ +badge((string) $record->similarGamesList->count()); + $count = $record->similarGamesList->count(); + $item->badge("{$count} / " . LinkSimilarGamesAction::MAX_SIMILAR_GAMES); } return [$item]; @@ -127,6 +129,10 @@ public function table(Table $table): Table ->headerActions([ Actions\Action::make('add') ->label('Add similar games') + ->disabled(fn (): bool => $this->isSimilarGamesCapReached()) + ->tooltip(fn (): ?string => $this->isSimilarGamesCapReached() + ? 'This game is at the ' . LinkSimilarGamesAction::MAX_SIMILAR_GAMES . '-similar-game cap. Remove a game before adding more.' + : null) ->schema([ Forms\Components\TextInput::make('game_ids_csv') ->label('Game IDs (CSV)') @@ -181,18 +187,31 @@ public function table(Table $table): Table /** @var Game $game */ $game = $this->getOwnerRecord(); - $gameIds = []; + $selectIds = !empty($data['game_ids']) ? array_map('intval', $data['game_ids']) : []; + $csvIds = !empty($data['game_ids_csv']) + ? (new ParseIdsFromCsvAction())->execute($data['game_ids_csv']) + : []; + $gameIds = array_values(array_unique(array_merge($selectIds, $csvIds))); - // Handle select field input. - if (!empty($data['game_ids'])) { - $gameIds = $data['game_ids']; - (new LinkSimilarGamesAction())->execute($game, $gameIds); + if (empty($gameIds)) { + return; } - // Handle CSV input. - if (!empty($data['game_ids_csv'])) { - $gameIds = (new ParseIdsFromCsvAction())->execute($data['game_ids_csv']); + try { (new LinkSimilarGamesAction())->execute($game, $gameIds); + } catch (SimilarGamesCapExceededException $e) { + $offending = $e->offendingGameId === $game->id + ? $game + : Game::find($e->offendingGameId); + $title = $offending?->title ?? "Game #{$e->offendingGameId}"; + + Notification::make() + ->danger() + ->title('Cap exceeded') + ->body("Cannot add: \"{$title}\" would exceed the {$e->cap}-similar-game cap. Remove an entry first.") + ->send(); + + return; } Notification::make() @@ -249,6 +268,18 @@ public function table(Table $table): Table ]); } + private ?bool $isSimilarGamesCapReached = null; + + private function isSimilarGamesCapReached(): bool + { + if ($this->isSimilarGamesCapReached === null) { + $similarGamesCount = $this->getOwnerRecord()->similarGamesList()->count(); + $this->isSimilarGamesCapReached = $similarGamesCount >= LinkSimilarGamesAction::MAX_SIMILAR_GAMES; + } + + return $this->isSimilarGamesCapReached; + } + /** * @param Builder $query * @return Builder diff --git a/app/Platform/Actions/LinkSimilarGamesAction.php b/app/Platform/Actions/LinkSimilarGamesAction.php index f279c6c6b9..ae8afa54a0 100644 --- a/app/Platform/Actions/LinkSimilarGamesAction.php +++ b/app/Platform/Actions/LinkSimilarGamesAction.php @@ -4,46 +4,71 @@ namespace App\Platform\Actions; +use App\Exceptions\SimilarGamesCapExceededException; use App\Models\Game; use App\Models\GameSet; -use App\Models\GameSetGame; use App\Platform\Enums\GameSetType; +use Illuminate\Support\Facades\DB; class LinkSimilarGamesAction { + public const int MAX_SIMILAR_GAMES = 6; + public function execute(Game $parentGame, array $gameIdsToLink): void { - $parentSimilarGamesSet = GameSet::firstOrCreate([ + $parentSimilarGamesSet = GameSet::firstOrNew([ 'type' => GameSetType::SimilarGames, 'game_id' => $parentGame->id, ]); - $existingSimilarGames = GameSetGame::query() - ->where('game_set_id', $parentSimilarGamesSet->id) - ->whereIn('game_id', $gameIdsToLink) - ->pluck('game_id') - ->toArray(); + $existingParentLinkedIds = $parentSimilarGamesSet->exists + ? $parentSimilarGamesSet->games()->pluck('games.id')->all() + : []; - $newSetGameIds = array_diff($gameIdsToLink, $existingSimilarGames); - if (!empty($newSetGameIds)) { - $parentSimilarGamesSet->games()->attach($newSetGameIds); + $newSetGameIds = array_values(array_diff($gameIdsToLink, $existingParentLinkedIds)); + + if (empty($newSetGameIds)) { + return; } - // Link each game's similar games set to include the parent game. - foreach ($gameIdsToLink as $gameId) { - $similarGamesSet = GameSet::firstOrCreate([ + $existingParentCount = count($existingParentLinkedIds); + if ($existingParentCount + count($newSetGameIds) > self::MAX_SIMILAR_GAMES) { + throw new SimilarGamesCapExceededException($parentGame->id, self::MAX_SIMILAR_GAMES); + } + + /** @var GameSet[] $targetSets */ + $targetSets = []; + foreach ($newSetGameIds as $gameId) { + $candidateSet = GameSet::firstOrNew([ 'type' => GameSetType::SimilarGames, 'game_id' => $gameId, ]); - $isAlreadyAttached = GameSetGame::query() - ->where('game_set_id', $similarGamesSet->id) - ->where('game_id', $parentGame->id) - ->exists(); + if ($candidateSet->exists && $candidateSet->games()->where('games.id', $parentGame->id)->exists()) { + continue; + } - if (!$isAlreadyAttached) { - $similarGamesSet->games()->attach($parentGame->id); + $candidateCount = $candidateSet->exists ? $candidateSet->games()->count() : 0; + if ($candidateCount + 1 > self::MAX_SIMILAR_GAMES) { + throw new SimilarGamesCapExceededException($gameId, self::MAX_SIMILAR_GAMES); } + + $targetSets[] = $candidateSet; } + + // writes only happen if validation passed + DB::transaction(function () use ($parentGame, $parentSimilarGamesSet, $newSetGameIds, $targetSets): void { + if (!$parentSimilarGamesSet->exists) { + $parentSimilarGamesSet->save(); + } + $parentSimilarGamesSet->games()->attach($newSetGameIds); + + foreach ($targetSets as $candidateSet) { + if (!$candidateSet->exists) { + $candidateSet->save(); + } + $candidateSet->games()->attach($parentGame->id); + } + }); } } From 556da56683b8ec72fedee7985426712b3e08a44f Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 11 Jun 2026 18:15:53 -0400 Subject: [PATCH 2/4] fix: remediate some stuff --- .../GameResource/Pages/SimilarGames.php | 4 +++- .../Actions/LinkSimilarGamesAction.php | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/Filament/Resources/GameResource/Pages/SimilarGames.php b/app/Filament/Resources/GameResource/Pages/SimilarGames.php index 02e465f785..4b5a248667 100644 --- a/app/Filament/Resources/GameResource/Pages/SimilarGames.php +++ b/app/Filament/Resources/GameResource/Pages/SimilarGames.php @@ -208,7 +208,7 @@ public function table(Table $table): Table Notification::make() ->danger() ->title('Cap exceeded') - ->body("Cannot add: \"{$title}\" would exceed the {$e->cap}-similar-game cap. Remove an entry first.") + ->body("Cannot add: {$title} would exceed the {$e->cap}-similar-game cap. Remove an entry first.") ->send(); return; @@ -233,6 +233,7 @@ public function table(Table $table): Table $game = $this->getOwnerRecord(); (new UnlinkSimilarGamesAction())->execute($game, [$similarGame->id]); + $this->isSimilarGamesCapReached = null; Notification::make() ->success() @@ -256,6 +257,7 @@ public function table(Table $table): Table $game, $similarGames->pluck('id')->toArray() ); + $this->isSimilarGamesCapReached = null; $this->deselectAllTableRecords(); diff --git a/app/Platform/Actions/LinkSimilarGamesAction.php b/app/Platform/Actions/LinkSimilarGamesAction.php index ae8afa54a0..f12abec927 100644 --- a/app/Platform/Actions/LinkSimilarGamesAction.php +++ b/app/Platform/Actions/LinkSimilarGamesAction.php @@ -27,10 +27,6 @@ public function execute(Game $parentGame, array $gameIdsToLink): void $newSetGameIds = array_values(array_diff($gameIdsToLink, $existingParentLinkedIds)); - if (empty($newSetGameIds)) { - return; - } - $existingParentCount = count($existingParentLinkedIds); if ($existingParentCount + count($newSetGameIds) > self::MAX_SIMILAR_GAMES) { throw new SimilarGamesCapExceededException($parentGame->id, self::MAX_SIMILAR_GAMES); @@ -38,7 +34,7 @@ public function execute(Game $parentGame, array $gameIdsToLink): void /** @var GameSet[] $targetSets */ $targetSets = []; - foreach ($newSetGameIds as $gameId) { + foreach ($gameIdsToLink as $gameId) { $candidateSet = GameSet::firstOrNew([ 'type' => GameSetType::SimilarGames, 'game_id' => $gameId, @@ -56,12 +52,18 @@ public function execute(Game $parentGame, array $gameIdsToLink): void $targetSets[] = $candidateSet; } + if (empty($newSetGameIds) && empty($targetSets)) { + return; + } + // writes only happen if validation passed DB::transaction(function () use ($parentGame, $parentSimilarGamesSet, $newSetGameIds, $targetSets): void { - if (!$parentSimilarGamesSet->exists) { - $parentSimilarGamesSet->save(); + if (!empty($newSetGameIds)) { + if (!$parentSimilarGamesSet->exists) { + $parentSimilarGamesSet->save(); + } + $parentSimilarGamesSet->games()->attach($newSetGameIds); } - $parentSimilarGamesSet->games()->attach($newSetGameIds); foreach ($targetSets as $candidateSet) { if (!$candidateSet->exists) { From 474cb17213a322d45df31207470822aded26f818 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 11 Jun 2026 18:17:31 -0400 Subject: [PATCH 3/4] chore: phpstan --- app/Filament/Resources/GameResource/Pages/SimilarGames.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Filament/Resources/GameResource/Pages/SimilarGames.php b/app/Filament/Resources/GameResource/Pages/SimilarGames.php index 4b5a248667..44f72ad65a 100644 --- a/app/Filament/Resources/GameResource/Pages/SimilarGames.php +++ b/app/Filament/Resources/GameResource/Pages/SimilarGames.php @@ -275,7 +275,9 @@ public function table(Table $table): Table private function isSimilarGamesCapReached(): bool { if ($this->isSimilarGamesCapReached === null) { - $similarGamesCount = $this->getOwnerRecord()->similarGamesList()->count(); + /** @var Game $game */ + $game = $this->getOwnerRecord(); + $similarGamesCount = $game->similarGamesList()->count(); $this->isSimilarGamesCapReached = $similarGamesCount >= LinkSimilarGamesAction::MAX_SIMILAR_GAMES; } From 923b41341f9ec5dd5b45dddf88361c5a6eec83a3 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Mon, 15 Jun 2026 16:58:13 -0400 Subject: [PATCH 4/4] fix: address feedback --- .../Resources/GameResource/Pages/SimilarGames.php | 13 ++++++++----- app/Platform/Actions/LinkSimilarGamesAction.php | 3 +++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/Filament/Resources/GameResource/Pages/SimilarGames.php b/app/Filament/Resources/GameResource/Pages/SimilarGames.php index 44f72ad65a..9bd2df922e 100644 --- a/app/Filament/Resources/GameResource/Pages/SimilarGames.php +++ b/app/Filament/Resources/GameResource/Pages/SimilarGames.php @@ -200,15 +200,18 @@ public function table(Table $table): Table try { (new LinkSimilarGamesAction())->execute($game, $gameIds); } catch (SimilarGamesCapExceededException $e) { - $offending = $e->offendingGameId === $game->id - ? $game - : Game::find($e->offendingGameId); - $title = $offending?->title ?? "Game #{$e->offendingGameId}"; + if ($e->offendingGameId === $game->id) { + $body = "Adding these games would push this game's similar list over the {$e->cap}-similar-game cap. Remove an entry or add fewer games."; + } else { + $offending = Game::find($e->offendingGameId); + $title = $offending?->title ?? "Game #{$e->offendingGameId}"; + $body = "{$title} is already at the {$e->cap}-similar-game cap and cannot be linked."; + } Notification::make() ->danger() ->title('Cap exceeded') - ->body("Cannot add: {$title} would exceed the {$e->cap}-similar-game cap. Remove an entry first.") + ->body($body) ->send(); return; diff --git a/app/Platform/Actions/LinkSimilarGamesAction.php b/app/Platform/Actions/LinkSimilarGamesAction.php index f12abec927..a98ed05f8f 100644 --- a/app/Platform/Actions/LinkSimilarGamesAction.php +++ b/app/Platform/Actions/LinkSimilarGamesAction.php @@ -16,6 +16,9 @@ class LinkSimilarGamesAction public function execute(Game $parentGame, array $gameIdsToLink): void { + // normalize to a list of ints + $gameIdsToLink = array_values(array_unique(array_map('intval', $gameIdsToLink))); + $parentSimilarGamesSet = GameSet::firstOrNew([ 'type' => GameSetType::SimilarGames, 'game_id' => $parentGame->id,