From 2a724c0ae2952b5f3ce61adea26c8b32c4cab14e Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 1 Jun 2026 00:24:05 +0300 Subject: [PATCH] Expand IGDB data: collections, game modes, themes, perspectives, videos, websites, age ratings, similar games; add backfill command Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/BackfillExtendedDataCommand.php | 95 +++++++ .../Commands/Concerns/UpsertsGames.php | 2 + app/Http/Controllers/GameController.php | 14 +- app/Models/Collection.php | 16 ++ app/Models/Game.php | 51 +++- app/Models/GameMode.php | 16 ++ app/Models/GameVideo.php | 26 ++ app/Models/GameWebsite.php | 31 +++ app/Models/PlayerPerspective.php | 16 ++ app/Models/Theme.php | 16 ++ app/Services/IgdbService.php | 237 ++++++++++++++---- ..._06_01_000001_create_collections_table.php | 16 ++ ...6_06_01_000002_create_game_modes_table.php | 16 ++ .../2026_06_01_000003_create_themes_table.php | 16 ++ ...00004_create_player_perspectives_table.php | 16 ++ ...05_add_extended_columns_to_games_table.php | 25 ++ ..._06_01_000006_create_game_videos_table.php | 17 ++ ...6_01_000007_create_game_websites_table.php | 16 ++ ...6_01_000008_create_similar_games_table.php | 16 ++ ..._06_01_000009_create_game_pivot_tables.php | 30 +++ resources/views/games/show.blade.php | 137 +++++++++- 21 files changed, 768 insertions(+), 57 deletions(-) create mode 100644 app/Console/Commands/BackfillExtendedDataCommand.php create mode 100644 app/Models/Collection.php create mode 100644 app/Models/GameMode.php create mode 100644 app/Models/GameVideo.php create mode 100644 app/Models/GameWebsite.php create mode 100644 app/Models/PlayerPerspective.php create mode 100644 app/Models/Theme.php create mode 100644 database/migrations/2026_06_01_000001_create_collections_table.php create mode 100644 database/migrations/2026_06_01_000002_create_game_modes_table.php create mode 100644 database/migrations/2026_06_01_000003_create_themes_table.php create mode 100644 database/migrations/2026_06_01_000004_create_player_perspectives_table.php create mode 100644 database/migrations/2026_06_01_000005_add_extended_columns_to_games_table.php create mode 100644 database/migrations/2026_06_01_000006_create_game_videos_table.php create mode 100644 database/migrations/2026_06_01_000007_create_game_websites_table.php create mode 100644 database/migrations/2026_06_01_000008_create_similar_games_table.php create mode 100644 database/migrations/2026_06_01_000009_create_game_pivot_tables.php diff --git a/app/Console/Commands/BackfillExtendedDataCommand.php b/app/Console/Commands/BackfillExtendedDataCommand.php new file mode 100644 index 0000000..e386f57 --- /dev/null +++ b/app/Console/Commands/BackfillExtendedDataCommand.php @@ -0,0 +1,95 @@ +option('limit'); + $offset = (int) $this->option('offset'); + + $games = Game::whereNotNull('igdb_id') + ->orderBy('id') + ->skip($offset) + ->take($limit) + ->get(); + + $total = $games->count(); + + if ($total === 0) { + $this->info('No games found to process.'); + return self::SUCCESS; + } + + $this->info("Processing {$total} games (offset: {$offset})…"); + $this->newLine(); + + $n = 0; + foreach ($games->chunk(10) as $batch) { + $igdbIds = $batch->pluck('igdb_id')->all(); + + try { + $igdbResults = $igdb->getGamesByIds($igdbIds); + } catch (\Exception $e) { + $this->error('IGDB request failed: ' . $e->getMessage()); + return self::FAILURE; + } + + $indexed = collect($igdbResults)->keyBy('id'); + + foreach ($batch as $game) { + $n++; + $igdbData = $indexed->get($game->igdb_id); + + if (!$igdbData) { + $this->warn(" [{$n}/{$total}] No IGDB data for: {$game->title} (igdb_id={$game->igdb_id})"); + continue; + } + + try { + $igdb->syncExtendedData($game, $igdbData); + $this->line(" [{$n}/{$total}] Processed: {$game->title}"); + } catch (\Exception $e) { + $this->warn(" [{$n}/{$total}] Failed {$game->title}: " . $e->getMessage()); + } + } + + usleep(250000); // 250ms between IGDB batches + } + + $this->newLine(); + $this->info('Extended data sync complete. Resolving similar_games pivot…'); + + $this->resolveSimilarGames(); + + $this->info('Done.'); + return self::SUCCESS; + } + + private function resolveSimilarGames(): void + { + $resolved = 0; + Game::whereNotNull('similar_game_igdb_ids')->chunk(100, function ($games) use (&$resolved) { + foreach ($games as $game) { + $ids = $game->similar_game_igdb_ids; + if (empty($ids)) continue; + + $similarIds = Game::whereIn('igdb_id', $ids)->pluck('id'); + $game->similarGames()->sync($similarIds); + $resolved++; + } + }); + $this->line(" Resolved similar_games for {$resolved} games."); + } +} diff --git a/app/Console/Commands/Concerns/UpsertsGames.php b/app/Console/Commands/Concerns/UpsertsGames.php index 7890bcf..a80006b 100644 --- a/app/Console/Commands/Concerns/UpsertsGames.php +++ b/app/Console/Commands/Concerns/UpsertsGames.php @@ -89,5 +89,7 @@ trait UpsertsGames ]); } } + + $igdb->syncExtendedData($game, $data); } } \ No newline at end of file diff --git a/app/Http/Controllers/GameController.php b/app/Http/Controllers/GameController.php index cac3d37..cc0fed7 100644 --- a/app/Http/Controllers/GameController.php +++ b/app/Http/Controllers/GameController.php @@ -47,7 +47,11 @@ class GameController extends Controller public function show(Game $game) { - $game->load(['genres', 'platforms', 'companies', 'screenshots', 'reviews.user']); + $game->load([ + 'genres', 'platforms', 'companies', 'screenshots', 'reviews.user', + 'collection', 'gameModes', 'themes', 'playerPerspectives', 'videos', 'websites', + 'similarGames' => fn($q) => $q->published()->with(['genres'])->limit(6), + ]); $userGame = null; $userReview = null; @@ -64,8 +68,10 @@ class GameController extends Controller ->limit(20) ->get(); - $similarGames = collect(); - if ($game->genres->isNotEmpty()) { + // Use IGDB similar_games if available, otherwise fall back to genre-based + if ($game->similarGames->isNotEmpty()) { + $similarGames = $game->similarGames; + } elseif ($game->genres->isNotEmpty()) { $similarGames = Game::published() ->whereHas('genres', fn($q) => $q->whereIn('genres.id', $game->genres->pluck('id'))) ->where('id', '!=', $game->id) @@ -73,6 +79,8 @@ class GameController extends Controller ->orderByDesc('rating_avg') ->limit(6) ->get(); + } else { + $similarGames = collect(); } $response = view('games.show', compact('game', 'userGame', 'userReview', 'userLists', 'comments', 'similarGames')); diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 0000000..5eee0b2 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,16 @@ +hasMany(Game::class); + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php index 5251060..ee8a116 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Cache; @@ -15,14 +16,16 @@ class Game extends Model use HasSlug, Searchable; protected $fillable = [ - 'igdb_id', 'slug', 'title', 'summary', 'cover_url', 'cover_thumb_url', + 'igdb_id', 'slug', 'title', 'summary', 'storyline', 'cover_url', 'cover_thumb_url', 'release_date', 'release_year', 'rating_avg', 'rating_count', 'review_count', - 'library_count', 'status', + 'library_count', 'status', 'collection_id', 'age_rating_pegi', 'age_rating_esrb', + 'aggregated_rating', 'aggregated_rating_count', 'similar_game_igdb_ids', ]; protected $casts = [ - 'release_date' => 'date', - 'rating_avg' => 'decimal:2', + 'release_date' => 'date', + 'rating_avg' => 'decimal:2', + 'similar_game_igdb_ids' => 'array', ]; public function getSlugOptions(): SlugOptions @@ -32,6 +35,11 @@ class Game extends Model public function getRouteKeyName(): string { return 'slug'; } + public function collection(): BelongsTo + { + return $this->belongsTo(Collection::class); + } + public function genres(): BelongsToMany { return $this->belongsToMany(Genre::class, 'game_genre'); @@ -67,6 +75,41 @@ class Game extends Model return $this->hasMany(GameComment::class); } + public function gameModes(): BelongsToMany + { + return $this->belongsToMany(GameMode::class, 'game_game_mode'); + } + + public function themes(): BelongsToMany + { + return $this->belongsToMany(Theme::class, 'game_theme'); + } + + public function playerPerspectives(): BelongsToMany + { + return $this->belongsToMany(PlayerPerspective::class, 'game_player_perspective'); + } + + public function videos(): HasMany + { + return $this->hasMany(GameVideo::class); + } + + public function websites(): HasMany + { + return $this->hasMany(GameWebsite::class); + } + + public function similarGames(): BelongsToMany + { + return $this->belongsToMany( + Game::class, + 'similar_games', + 'game_id', + 'similar_game_id' + ); + } + public function toSearchableArray(): array { return [ diff --git a/app/Models/GameMode.php b/app/Models/GameMode.php new file mode 100644 index 0000000..b746420 --- /dev/null +++ b/app/Models/GameMode.php @@ -0,0 +1,16 @@ +belongsToMany(Game::class, 'game_game_mode'); + } +} diff --git a/app/Models/GameVideo.php b/app/Models/GameVideo.php new file mode 100644 index 0000000..ed72cd0 --- /dev/null +++ b/app/Models/GameVideo.php @@ -0,0 +1,26 @@ +belongsTo(Game::class); + } + + public function getYoutubeUrlAttribute(): string + { + return "https://www.youtube.com/watch?v={$this->video_id}"; + } + + public function getYoutubeEmbedUrlAttribute(): string + { + return "https://www.youtube.com/embed/{$this->video_id}"; + } +} diff --git a/app/Models/GameWebsite.php b/app/Models/GameWebsite.php new file mode 100644 index 0000000..640a69e --- /dev/null +++ b/app/Models/GameWebsite.php @@ -0,0 +1,31 @@ + 'Official', + 2 => 'Wikia', + 3 => 'Wikipedia', + 9 => 'YouTube', + 13 => 'Steam', + 16 => 'Epic Games', + 17 => 'GOG', + ]; + + public function game(): BelongsTo + { + return $this->belongsTo(Game::class); + } + + public function getLabelAttribute(): string + { + return self::CATEGORY_LABELS[$this->category] ?? 'Website'; + } +} diff --git a/app/Models/PlayerPerspective.php b/app/Models/PlayerPerspective.php new file mode 100644 index 0000000..2be8a2c --- /dev/null +++ b/app/Models/PlayerPerspective.php @@ -0,0 +1,16 @@ +belongsToMany(Game::class, 'game_player_perspective'); + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 0000000..0c8e932 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,16 @@ +belongsToMany(Game::class, 'game_theme'); + } +} diff --git a/app/Services/IgdbService.php b/app/Services/IgdbService.php index 027cfd3..a41e392 100644 --- a/app/Services/IgdbService.php +++ b/app/Services/IgdbService.php @@ -2,6 +2,14 @@ namespace App\Services; +use App\Models\Collection; +use App\Models\Company; +use App\Models\Game; +use App\Models\GameMode; +use App\Models\GameVideo; +use App\Models\GameWebsite; +use App\Models\PlayerPerspective; +use App\Models\Theme; use GuzzleHttp\Client; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -13,6 +21,10 @@ class IgdbService protected string $clientSecret; protected string $baseUrl; + // IGDB age_rating category: 1=ESRB, 2=PEGI + private const ESRB_LABELS = [6 => 'RP', 7 => 'EC', 8 => 'E', 9 => 'E10+', 10 => 'T', 11 => 'M', 12 => 'AO']; + private const PEGI_LABELS = [1 => '3', 2 => '7', 3 => '12', 4 => '16', 5 => '18']; + public function __construct() { $this->client = new Client(['timeout' => 10]); @@ -60,6 +72,27 @@ class IgdbService } } + private function extendedFields(): string + { + return 'id,name,slug,summary,storyline,cover.image_id,first_release_date, + genres.id,genres.name,genres.slug, + platforms.id,platforms.name,platforms.slug,platforms.abbreviation, + involved_companies.company.id,involved_companies.company.name, + involved_companies.company.slug,involved_companies.developer, + involved_companies.publisher,involved_companies.porting, + screenshots.image_id, + rating,rating_count, + aggregated_rating,aggregated_rating_count, + collection.id,collection.name,collection.slug, + game_modes.id,game_modes.name,game_modes.slug, + themes.id,themes.name,themes.slug, + player_perspectives.id,player_perspectives.name,player_perspectives.slug, + videos.id,videos.name,videos.video_id, + websites.category,websites.url, + age_ratings.category,age_ratings.rating, + similar_games'; + } + /** * Search games by title. */ @@ -81,34 +114,33 @@ class IgdbService public function getGame(int $igdbId): ?array { $results = $this->query('games', " - fields id,name,slug,summary,cover.image_id,first_release_date, - genres.id,genres.name,genres.slug, - platforms.id,platforms.name,platforms.slug,platforms.abbreviation, - involved_companies.company.id,involved_companies.company.name, - involved_companies.company.slug,involved_companies.developer, - involved_companies.publisher, - screenshots.image_id, - rating,rating_count; + fields {$this->extendedFields()}; where id = {$igdbId}; limit 1; "); return $results[0] ?? null; } + /** + * Fetch fresh extended data for a set of IGDB IDs (for backfill). + */ + public function getGamesByIds(array $igdbIds): array + { + $ids = implode(',', $igdbIds); + return $this->query('games', " + fields {$this->extendedFields()}; + where id = ({$ids}); + limit " . count($igdbIds) . "; + "); + } + /** * Get popular games (by rating and rating count). */ public function getPopularGames(int $limit = 20, int $offset = 0): array { return $this->query('games', " - fields id,name,slug,summary,cover.image_id,first_release_date, - genres.id,genres.name,genres.slug, - platforms.id,platforms.name,platforms.slug,platforms.abbreviation, - involved_companies.company.id,involved_companies.company.name, - involved_companies.company.slug,involved_companies.developer, - involved_companies.publisher, - screenshots.image_id, - rating,rating_count; + fields {$this->extendedFields()}; where rating != null & rating_count > 100 & cover != null; sort rating_count desc; limit {$limit}; @@ -122,14 +154,7 @@ class IgdbService public function getPopularGamesForBackfill(int $limit = 500, int $offset = 0, int $minRatings = 5): array { return $this->query('games', " - fields id,name,slug,summary,cover.image_id,first_release_date, - genres.id,genres.name,genres.slug, - platforms.id,platforms.name,platforms.slug,platforms.abbreviation, - involved_companies.company.id,involved_companies.company.name, - involved_companies.company.slug,involved_companies.developer, - involved_companies.publisher, - screenshots.image_id, - rating,rating_count; + fields {$this->extendedFields()}; where rating != null & rating_count > {$minRatings} & cover != null; sort rating_count desc; limit {$limit}; @@ -146,14 +171,7 @@ class IgdbService $now = now()->timestamp; return $this->query('games', " - fields id,name,slug,summary,cover.image_id,first_release_date, - genres.id,genres.name,genres.slug, - platforms.id,platforms.name,platforms.slug,platforms.abbreviation, - involved_companies.company.id,involved_companies.company.name, - involved_companies.company.slug,involved_companies.developer, - involved_companies.publisher, - screenshots.image_id, - rating,rating_count; + fields {$this->extendedFields()}; where first_release_date >= {$timestamp} & first_release_date <= {$now} & cover != null; sort first_release_date desc; limit {$limit}; @@ -169,14 +187,7 @@ class IgdbService $to = mktime(23, 59, 59, 12, 31, $year); return $this->query('games', " - fields id,name,slug,summary,cover.image_id,first_release_date, - genres.id,genres.name,genres.slug, - platforms.id,platforms.name,platforms.slug,platforms.abbreviation, - involved_companies.company.id,involved_companies.company.name, - involved_companies.company.slug,involved_companies.developer, - involved_companies.publisher, - screenshots.image_id, - rating,rating_count; + fields {$this->extendedFields()}; where first_release_date >= {$from} & first_release_date <= {$to} & cover != null; sort rating_count desc; limit {$limit}; @@ -190,14 +201,7 @@ class IgdbService public function getGamesByPlatform(int $platformIgdbId, int $limit = 20, int $offset = 0): array { return $this->query('games', " - fields id,name,slug,summary,cover.image_id,first_release_date, - genres.id,genres.name,genres.slug, - platforms.id,platforms.name,platforms.slug,platforms.abbreviation, - involved_companies.company.id,involved_companies.company.name, - involved_companies.company.slug,involved_companies.developer, - involved_companies.publisher, - screenshots.image_id, - rating,rating_count; + fields {$this->extendedFields()}; where platforms = [{$platformIgdbId}] & cover != null & rating != null; sort rating_count desc; limit {$limit}; @@ -221,6 +225,143 @@ class IgdbService "); } + /** + * Sync all extended IGDB data onto a Game model. + */ + public function syncExtendedData(Game $game, array $data): void + { + $updates = []; + + // Collection + if (!empty($data['collection'])) { + $col = $data['collection']; + $collection = Collection::firstOrCreate( + ['igdb_id' => $col['id']], + ['name' => $col['name'], 'slug' => $col['slug'] ?? \Str::slug($col['name'])] + ); + $updates['collection_id'] = $collection->id; + } + + // Storyline + if (!empty($data['storyline'])) { + $updates['storyline'] = $data['storyline']; + } + + // Aggregated rating (critics) + if (isset($data['aggregated_rating'])) { + $updates['aggregated_rating'] = round($data['aggregated_rating'], 2); + } + if (isset($data['aggregated_rating_count'])) { + $updates['aggregated_rating_count'] = $data['aggregated_rating_count']; + } + + // Age ratings + if (!empty($data['age_ratings'])) { + foreach ($data['age_ratings'] as $ar) { + if (($ar['category'] ?? null) === 1 && isset($ar['rating'])) { + $updates['age_rating_esrb'] = self::ESRB_LABELS[$ar['rating']] ?? null; + } + if (($ar['category'] ?? null) === 2 && isset($ar['rating'])) { + $updates['age_rating_pegi'] = self::PEGI_LABELS[$ar['rating']] ?? null; + } + } + } + + // Similar games — store IGDB IDs for later resolution + if (!empty($data['similar_games'])) { + $updates['similar_game_igdb_ids'] = array_values( + array_filter(array_map(fn($s) => is_array($s) ? ($s['id'] ?? null) : $s, $data['similar_games'])) + ); + } + + if (!empty($updates)) { + $game->update($updates); + } + + // Game modes + if (!empty($data['game_modes'])) { + $ids = []; + foreach ($data['game_modes'] as $gm) { + $model = GameMode::firstOrCreate( + ['igdb_id' => $gm['id']], + ['name' => $gm['name'], 'slug' => $gm['slug'] ?? \Str::slug($gm['name'])] + ); + $ids[] = $model->id; + } + $game->gameModes()->sync($ids); + } + + // Themes + if (!empty($data['themes'])) { + $ids = []; + foreach ($data['themes'] as $t) { + $model = Theme::firstOrCreate( + ['igdb_id' => $t['id']], + ['name' => $t['name'], 'slug' => $t['slug'] ?? \Str::slug($t['name'])] + ); + $ids[] = $model->id; + } + $game->themes()->sync($ids); + } + + // Player perspectives + if (!empty($data['player_perspectives'])) { + $ids = []; + foreach ($data['player_perspectives'] as $pp) { + $model = PlayerPerspective::firstOrCreate( + ['igdb_id' => $pp['id']], + ['name' => $pp['name'], 'slug' => $pp['slug'] ?? \Str::slug($pp['name'])] + ); + $ids[] = $model->id; + } + $game->playerPerspectives()->sync($ids); + } + + // Videos + if (isset($data['videos'])) { + GameVideo::where('game_id', $game->id)->delete(); + foreach ($data['videos'] as $v) { + if (empty($v['video_id'])) continue; + GameVideo::create([ + 'game_id' => $game->id, + 'igdb_id' => $v['id'] ?? null, + 'name' => $v['name'] ?? null, + 'video_id' => $v['video_id'], + ]); + } + } + + // Websites + if (isset($data['websites'])) { + GameWebsite::where('game_id', $game->id)->delete(); + foreach ($data['websites'] as $w) { + if (empty($w['url'])) continue; + GameWebsite::create([ + 'game_id' => $game->id, + 'category' => $w['category'], + 'url' => $w['url'], + ]); + } + } + + // Involved companies (update existing game_company pivot with richer data) + if (!empty($data['involved_companies'])) { + $companyPivot = []; + foreach ($data['involved_companies'] as $ic) { + if (empty($ic['company']['name'])) continue; + $company = Company::firstOrCreate( + ['igdb_id' => $ic['company']['id']], + ['name' => $ic['company']['name'], 'slug' => \Str::slug($ic['company']['name'])] + ); + $role = $ic['developer'] ? 'developer' : ($ic['publisher'] ? 'publisher' : 'other'); + $companyPivot[$company->id] = ['role' => $role]; + } + if (!empty($companyPivot)) { + $game->companies()->sync($companyPivot); + } + } + } + /** * Build a full cover image URL from an IGDB image_id. */ diff --git a/database/migrations/2026_06_01_000001_create_collections_table.php b/database/migrations/2026_06_01_000001_create_collections_table.php new file mode 100644 index 0000000..861be35 --- /dev/null +++ b/database/migrations/2026_06_01_000001_create_collections_table.php @@ -0,0 +1,16 @@ +id(); + $table->unsignedInteger('igdb_id')->unique(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('collections'); } +}; diff --git a/database/migrations/2026_06_01_000002_create_game_modes_table.php b/database/migrations/2026_06_01_000002_create_game_modes_table.php new file mode 100644 index 0000000..62ffd19 --- /dev/null +++ b/database/migrations/2026_06_01_000002_create_game_modes_table.php @@ -0,0 +1,16 @@ +id(); + $table->unsignedInteger('igdb_id')->unique(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('game_modes'); } +}; diff --git a/database/migrations/2026_06_01_000003_create_themes_table.php b/database/migrations/2026_06_01_000003_create_themes_table.php new file mode 100644 index 0000000..900e3d0 --- /dev/null +++ b/database/migrations/2026_06_01_000003_create_themes_table.php @@ -0,0 +1,16 @@ +id(); + $table->unsignedInteger('igdb_id')->unique(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('themes'); } +}; diff --git a/database/migrations/2026_06_01_000004_create_player_perspectives_table.php b/database/migrations/2026_06_01_000004_create_player_perspectives_table.php new file mode 100644 index 0000000..dbce07a --- /dev/null +++ b/database/migrations/2026_06_01_000004_create_player_perspectives_table.php @@ -0,0 +1,16 @@ +id(); + $table->unsignedInteger('igdb_id')->unique(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('player_perspectives'); } +}; diff --git a/database/migrations/2026_06_01_000005_add_extended_columns_to_games_table.php b/database/migrations/2026_06_01_000005_add_extended_columns_to_games_table.php new file mode 100644 index 0000000..1f2d740 --- /dev/null +++ b/database/migrations/2026_06_01_000005_add_extended_columns_to_games_table.php @@ -0,0 +1,25 @@ +unsignedBigInteger('collection_id')->nullable()->after('igdb_id'); + $table->string('age_rating_pegi', 10)->nullable()->after('summary'); + $table->string('age_rating_esrb', 10)->nullable()->after('age_rating_pegi'); + $table->decimal('aggregated_rating', 5, 2)->nullable()->after('age_rating_esrb'); + $table->unsignedInteger('aggregated_rating_count')->nullable()->after('aggregated_rating'); + $table->text('storyline')->nullable()->after('summary'); + $table->json('similar_game_igdb_ids')->nullable()->after('storyline'); + + $table->foreign('collection_id')->references('id')->on('collections')->nullOnDelete(); + }); + } + public function down(): void { + Schema::table('games', function (Blueprint $table) { + $table->dropForeign(['collection_id']); + $table->dropColumn(['collection_id', 'age_rating_pegi', 'age_rating_esrb', 'aggregated_rating', 'aggregated_rating_count', 'storyline', 'similar_game_igdb_ids']); + }); + } +}; diff --git a/database/migrations/2026_06_01_000006_create_game_videos_table.php b/database/migrations/2026_06_01_000006_create_game_videos_table.php new file mode 100644 index 0000000..a13b29a --- /dev/null +++ b/database/migrations/2026_06_01_000006_create_game_videos_table.php @@ -0,0 +1,17 @@ +id(); + $table->foreignId('game_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('igdb_id')->nullable(); + $table->string('name')->nullable(); + $table->string('video_id'); + $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('game_videos'); } +}; diff --git a/database/migrations/2026_06_01_000007_create_game_websites_table.php b/database/migrations/2026_06_01_000007_create_game_websites_table.php new file mode 100644 index 0000000..e05c624 --- /dev/null +++ b/database/migrations/2026_06_01_000007_create_game_websites_table.php @@ -0,0 +1,16 @@ +id(); + $table->foreignId('game_id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('category'); + $table->string('url'); + $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('game_websites'); } +}; diff --git a/database/migrations/2026_06_01_000008_create_similar_games_table.php b/database/migrations/2026_06_01_000008_create_similar_games_table.php new file mode 100644 index 0000000..1d4f14f --- /dev/null +++ b/database/migrations/2026_06_01_000008_create_similar_games_table.php @@ -0,0 +1,16 @@ +unsignedBigInteger('game_id'); + $table->unsignedBigInteger('similar_game_id'); + $table->primary(['game_id', 'similar_game_id']); + $table->foreign('game_id')->references('id')->on('games')->cascadeOnDelete(); + $table->foreign('similar_game_id')->references('id')->on('games')->cascadeOnDelete(); + }); + } + public function down(): void { Schema::dropIfExists('similar_games'); } +}; diff --git a/database/migrations/2026_06_01_000009_create_game_pivot_tables.php b/database/migrations/2026_06_01_000009_create_game_pivot_tables.php new file mode 100644 index 0000000..85bdbd4 --- /dev/null +++ b/database/migrations/2026_06_01_000009_create_game_pivot_tables.php @@ -0,0 +1,30 @@ +foreignId('game_id')->constrained()->cascadeOnDelete(); + $table->foreignId('game_mode_id')->constrained('game_modes')->cascadeOnDelete(); + $table->primary(['game_id', 'game_mode_id']); + }); + + Schema::create('game_theme', function (Blueprint $table) { + $table->foreignId('game_id')->constrained()->cascadeOnDelete(); + $table->foreignId('theme_id')->constrained('themes')->cascadeOnDelete(); + $table->primary(['game_id', 'theme_id']); + }); + + Schema::create('game_player_perspective', function (Blueprint $table) { + $table->foreignId('game_id')->constrained()->cascadeOnDelete(); + $table->foreignId('player_perspective_id')->constrained('player_perspectives')->cascadeOnDelete(); + $table->primary(['game_id', 'player_perspective_id']); + }); + } + public function down(): void { + Schema::dropIfExists('game_player_perspective'); + Schema::dropIfExists('game_theme'); + Schema::dropIfExists('game_game_mode'); + } +}; diff --git a/resources/views/games/show.blade.php b/resources/views/games/show.blade.php index 24dfbbf..47b1e5d 100644 --- a/resources/views/games/show.blade.php +++ b/resources/views/games/show.blade.php @@ -236,7 +236,7 @@ {{-- Companies --}} @if($game->companies->isNotEmpty()) -
+
@php $developers = $game->companies->where('pivot.role', 'developer'); $publishers = $game->companies->where('pivot.role', 'publisher'); @@ -250,6 +250,87 @@
@endif + {{-- Extended metadata --}} +
+ @if($game->collection) +
+ Series / Collection + {{ $game->collection->name }} +
+ @endif + @if($game->gameModes->isNotEmpty()) +
+ Game Modes + {{ $game->gameModes->pluck('name')->join(', ') }} +
+ @endif + @if($game->themes->isNotEmpty()) +
+ Themes + {{ $game->themes->pluck('name')->join(', ') }} +
+ @endif + @if($game->playerPerspectives->isNotEmpty()) +
+ Perspective + {{ $game->playerPerspectives->pluck('name')->join(', ') }} +
+ @endif + @if($game->aggregated_rating) +
+ Critics Score + + {{ round($game->aggregated_rating) }}/100 + + @if($game->aggregated_rating_count) + ({{ $game->aggregated_rating_count }} {{ Str::plural('review', $game->aggregated_rating_count) }}) + @endif +
+ @endif + @if($game->age_rating_pegi || $game->age_rating_esrb) +
+ Age Ratings +
+ @if($game->age_rating_pegi) + PEGI {{ $game->age_rating_pegi }} + @endif + @if($game->age_rating_esrb) + ESRB {{ $game->age_rating_esrb }} + @endif +
+
+ @endif +
+ + {{-- Websites --}} + @php + $importantCategories = [1, 13, 17, 16]; + $featuredSites = $game->websites->whereIn('category', $importantCategories)->sortBy(fn($w) => array_search($w->category, $importantCategories)); + @endphp + @if($featuredSites->isNotEmpty()) + + @endif +
@@ -261,6 +342,52 @@ @endif + {{-- Storyline (only if different from summary) --}} + @if($game->storyline && $game->storyline !== $game->summary) +
+

Storyline

+

{{ $game->storyline }}

+
+ @endif + + {{-- Trailers --}} + @if($game->videos->isNotEmpty()) +
+

Videos

+
+
+
+ +
+

+
+ @if($game->videos->count() > 1) +
+ @foreach($game->videos as $video) + + @endforeach +
+ @endif +
+
+ @endif + {{-- Screenshots --}} @if($game->screenshots->isNotEmpty())
isNotEmpty())
-

You Might Also Like

+

+ @if($game->similarGames->isNotEmpty()) + Similar Games + @else + You Might Also Like + @endif +

@foreach($similarGames as $similar)