= ({
return null;
}
+ const dimensionProps =
+ expectedWidth && expectedHeight ? { width: expectedWidth, height: expectedHeight } : {};
+
return (
diff --git a/resources/js/features/games/components/+show-mobile/GameShowMobileRoot.tsx b/resources/js/features/games/components/+show-mobile/GameShowMobileRoot.tsx
index d415f2bb08..24e33f9fb8 100644
--- a/resources/js/features/games/components/+show-mobile/GameShowMobileRoot.tsx
+++ b/resources/js/features/games/components/+show-mobile/GameShowMobileRoot.tsx
@@ -142,6 +142,8 @@ export const GameShowMobileRoot: FC = () => {
imageIngameUrl={game.imageIngameUrl!}
imageTitleUrl={game.imageTitleUrl!}
isPixelated={getIsSystemPixelated(game.system!.id)}
+ expectedWidth={game.system?.screenshotResolutions?.[0]?.width}
+ expectedHeight={game.system?.screenshotResolutions?.[0]?.height}
/>
diff --git a/resources/js/features/games/components/+show/GameShowMainRoot.tsx b/resources/js/features/games/components/+show/GameShowMainRoot.tsx
index def454a049..8af890c1c5 100644
--- a/resources/js/features/games/components/+show/GameShowMainRoot.tsx
+++ b/resources/js/features/games/components/+show/GameShowMainRoot.tsx
@@ -38,6 +38,8 @@ export const GameShowMainRoot: FC = () => {
imageIngameUrl={game.imageIngameUrl!}
imageTitleUrl={game.imageTitleUrl!}
isPixelated={getIsSystemPixelated(game.system!.id)}
+ expectedWidth={game.system?.screenshotResolutions?.[0]?.width}
+ expectedHeight={game.system?.screenshotResolutions?.[0]?.height}
/>
diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts
index 09e81992c4..7404ca6492 100644
--- a/resources/js/types/generated.d.ts
+++ b/resources/js/types/generated.d.ts
@@ -1173,6 +1173,7 @@ declare namespace App.Platform.Data {
nameFull?: string;
nameShort?: string;
iconUrl?: string;
+ screenshotResolutions?: Array<{ width: number; height: number }> | null;
};
export type SystemGameListPageProps = {
system: App.Platform.Data.System;
@@ -1210,9 +1211,9 @@ declare namespace App.Platform.Data {
};
}
declare namespace App.Platform.Enums {
- export type UnlockMode = 0 | 1;
export type AchievementAuthorTask = 'artwork' | 'design' | 'logic' | 'testing' | 'writing';
export type AchievementSetAuthorTask = 'artwork' | 'banner';
+ export type UnlockMode = 0 | 1;
export type AchievementSetType =
| 'core'
| 'bonus'
@@ -1278,6 +1279,7 @@ declare namespace App.Platform.Enums {
| 'na'
| 'worldwide'
| 'other';
+ export type GameScreenshotStatus = 'approved' | 'pending' | 'rejected';
export type GameSetRolePermission = 'view' | 'update';
export type GameSetType = 'hub' | 'similar-games';
export type GameSuggestionReason =
@@ -1305,6 +1307,7 @@ declare namespace App.Platform.Enums {
| 'hacks_beaten'
| 'all_beaten';
export type ReleasedAtGranularity = 'day' | 'month' | 'year';
+ export type ScreenshotType = 'title' | 'ingame' | 'completion';
export type TicketableType = 'achievement' | 'leaderboard' | 'game.rich-presence';
export type TriggerableType = 'achievement' | 'leaderboard' | 'game';
}
diff --git a/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php b/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php
new file mode 100644
index 0000000000..f9123d826e
--- /dev/null
+++ b/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php
@@ -0,0 +1,277 @@
+create(['system_id' => System::factory()]);
+ $file = UploadedFile::fake()->image('screenshot.png', 256, 224);
+
+ // ACT
+ $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+
+ // ASSERT
+ expect($screenshot)->toBeInstanceOf(GameScreenshot::class);
+ expect($screenshot->game_id)->toEqual($game->id);
+ expect($screenshot->type)->toEqual(ScreenshotType::Ingame);
+ expect($screenshot->status)->toEqual(GameScreenshotStatus::Approved);
+ expect($screenshot->is_primary)->toBeTrue();
+ expect($screenshot->media_id)->not->toBeNull();
+
+ $media = $game->fresh()->getMedia('screenshots')->first();
+ expect($media->getCustomProperty('sha1'))->not->toBeNull();
+});
+
+it('does not set subsequent screenshots as primary', function () {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ $action = new AddGameScreenshotAction();
+ $action->execute($game, UploadedFile::fake()->image('first.png', 256, 224), ScreenshotType::Ingame);
+
+ // ACT
+ $second = $action->execute($game, UploadedFile::fake()->image('second.png', 320, 240), ScreenshotType::Ingame);
+
+ // ASSERT
+ expect($second->is_primary)->toBeFalse();
+});
+
+it('demotes existing primary image when a new screenshot is forced as primary', function () {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ $action = new AddGameScreenshotAction();
+ $first = $action->execute($game, UploadedFile::fake()->image('first.png', 256, 224), ScreenshotType::Ingame);
+
+ // ACT
+ $second = $action->execute($game, UploadedFile::fake()->image('second.png', 320, 240), ScreenshotType::Ingame, isPrimary: true);
+
+ // ASSERT
+ expect($second->is_primary)->toBeTrue();
+ expect($second->status)->toEqual(GameScreenshotStatus::Approved);
+ expect($first->fresh()->is_primary)->toBeFalse();
+ expect($first->fresh()->status)->toEqual(GameScreenshotStatus::Pending);
+});
+
+it('rejects duplicate images for the same game', function () {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ $action = new AddGameScreenshotAction();
+
+ // ... create the source image content, then make two UploadedFile instances from it ...
+ $source = UploadedFile::fake()->image('screenshot.png', 256, 224);
+ $sourceContent = file_get_contents($source->getRealPath());
+
+ $action->execute($game, $source, ScreenshotType::Ingame);
+
+ $duplicate = UploadedFile::fake()->image('duplicate.png', 256, 224);
+ file_put_contents($duplicate->getRealPath(), $sourceContent);
+
+ // ASSERT
+ $action->execute($game->fresh(), $duplicate, ScreenshotType::Ingame);
+})->throws(ValidationException::class);
+
+it('enforces a cap of 20 approved ingame screenshots', function () {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ GameScreenshot::factory()->count(20)->for($game)->ingame()->create();
+ $file = UploadedFile::fake()->image('screenshot.png', 256, 224);
+
+ // ASSERT
+ (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+})->throws(ValidationException::class);
+
+it('does not enforce the ingame cap for title screenshots', function () {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ GameScreenshot::factory()->count(20)->for($game)->ingame()->create();
+ $file = UploadedFile::fake()->image('title.png', 256, 224);
+
+ // ACT
+ $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Title);
+
+ // ASSERT
+ expect($screenshot->type)->toEqual(ScreenshotType::Title);
+});
+
+it('enforces a cap of 1 approved screenshot for title and completion types', function (ScreenshotType $type) {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ GameScreenshot::factory()->for($game)->state(['type' => $type])->create();
+ $file = UploadedFile::fake()->image('screenshot.png', 256, 224);
+
+ // ASSERT
+ (new AddGameScreenshotAction())->execute($game, $file, $type);
+})->throws(ValidationException::class)->with([
+ 'title' => [ScreenshotType::Title],
+ 'completion' => [ScreenshotType::Completion],
+]);
+
+it('rejects a file smaller than 64x64', function () {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ $file = UploadedFile::fake()->image('tiny.png', 32, 32);
+
+ // ASSERT
+ (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+})->throws(ValidationException::class);
+
+it('rejects a file larger than 1920x1080', function () {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ $file = UploadedFile::fake()->image('huge.png', 2560, 1440);
+
+ // ASSERT
+ (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+})->throws(ValidationException::class);
+
+it('stores the description', function () {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ $file = UploadedFile::fake()->image('screenshot.png', 256, 224);
+
+ // ACT
+ $screenshot = (new AddGameScreenshotAction())->execute(
+ $game,
+ $file,
+ ScreenshotType::Ingame,
+ description: 'Boss fight in stage 3',
+ );
+
+ // ASSERT
+ expect($screenshot->description)->toEqual('Boss fight in stage 3');
+});
+
+it('accepts a screenshot matching an exact base resolution', function () {
+ // ARRANGE
+ $system = System::factory()->create([
+ 'screenshot_resolutions' => [['width' => 256, 'height' => 224]],
+ 'supports_resolution_scaling' => false,
+ ]);
+ $game = Game::factory()->create(['system_id' => $system->id]);
+ $file = UploadedFile::fake()->image('screenshot.png', 256, 224);
+
+ // ACT
+ $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+
+ // ASSERT
+ expect($screenshot)->toBeInstanceOf(GameScreenshot::class);
+});
+
+it('rejects a screenshot with wrong dimensions for the system', function () {
+ // ARRANGE
+ $system = System::factory()->create([
+ 'screenshot_resolutions' => [['width' => 256, 'height' => 224]],
+ 'supports_resolution_scaling' => false,
+ ]);
+ $game = Game::factory()->create(['system_id' => $system->id]);
+ $file = UploadedFile::fake()->image('screenshot.png', 320, 240);
+
+ // ASSERT
+ (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+})->throws(ValidationException::class);
+
+it('accepts a 2x scaled screenshot when the system supports resolution scaling', function () {
+ // ARRANGE
+ $system = System::factory()->create([
+ 'screenshot_resolutions' => [['width' => 320, 'height' => 240]],
+ 'supports_resolution_scaling' => true,
+ ]);
+ $game = Game::factory()->create(['system_id' => $system->id]);
+ $file = UploadedFile::fake()->image('screenshot.png', 640, 480);
+
+ // ACT
+ $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+
+ // ASSERT
+ expect($screenshot)->toBeInstanceOf(GameScreenshot::class);
+});
+
+it('accepts a 3x scaled screenshot when the system supports resolution scaling', function () {
+ // ARRANGE
+ $system = System::factory()->create([
+ 'screenshot_resolutions' => [['width' => 320, 'height' => 240]],
+ 'supports_resolution_scaling' => true,
+ ]);
+ $game = Game::factory()->create(['system_id' => $system->id]);
+ $file = UploadedFile::fake()->image('screenshot.png', 960, 720);
+
+ // ACT
+ $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+
+ // ASSERT
+ expect($screenshot)->toBeInstanceOf(GameScreenshot::class);
+});
+
+it('rejects a 4x scaled screenshot even when the system supports resolution scaling', function () {
+ // ARRANGE
+ $system = System::factory()->create([
+ 'screenshot_resolutions' => [['width' => 160, 'height' => 144]],
+ 'supports_resolution_scaling' => true,
+ ]);
+ $game = Game::factory()->create(['system_id' => $system->id]);
+ $file = UploadedFile::fake()->image('screenshot.png', 640, 576);
+
+ // ASSERT
+ (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+})->throws(ValidationException::class);
+
+it('rejects a scaled screenshot when the system does not support resolution scaling', function () {
+ // ARRANGE
+ $system = System::factory()->create([
+ 'screenshot_resolutions' => [['width' => 256, 'height' => 224]],
+ 'supports_resolution_scaling' => false,
+ ]);
+ $game = Game::factory()->create(['system_id' => $system->id]);
+ $file = UploadedFile::fake()->image('screenshot.png', 512, 448);
+
+ // ASSERT
+ (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+})->throws(ValidationException::class);
+
+it('allows any dimensions when the system has null resolutions', function () {
+ // ARRANGE
+ $system = System::factory()->create([
+ 'screenshot_resolutions' => null,
+ 'supports_resolution_scaling' => false,
+ ]);
+ $game = Game::factory()->create(['system_id' => $system->id]);
+ $file = UploadedFile::fake()->image('screenshot.png', 800, 600);
+
+ // ACT
+ $screenshot = (new AddGameScreenshotAction())->execute($game, $file, ScreenshotType::Ingame);
+
+ // ASSERT
+ expect($screenshot)->toBeInstanceOf(GameScreenshot::class);
+});
+
+it('treats different screenshot types independently for primary', function () {
+ // ARRANGE
+ $game = Game::factory()->create(['system_id' => System::factory()]);
+ $action = new AddGameScreenshotAction();
+
+ // ACT
+ $ingame = $action->execute($game, UploadedFile::fake()->image('ingame.png', 256, 224), ScreenshotType::Ingame);
+ $title = $action->execute($game, UploadedFile::fake()->image('title.png', 320, 240), ScreenshotType::Title);
+
+ // ASSERT
+ // ... both should be primary since they're different types ...
+ expect($ingame->is_primary)->toBeTrue();
+ expect($title->is_primary)->toBeTrue();
+});
diff --git a/tests/Feature/Platform/Observers/GameScreenshotObserverTest.php b/tests/Feature/Platform/Observers/GameScreenshotObserverTest.php
new file mode 100644
index 0000000000..96440faa75
--- /dev/null
+++ b/tests/Feature/Platform/Observers/GameScreenshotObserverTest.php
@@ -0,0 +1,121 @@
+ Game::class,
+ 'model_id' => $game->id,
+ 'uuid' => (string) Str::uuid(),
+ 'collection_name' => 'screenshots',
+ 'name' => 'screenshot',
+ 'file_name' => 'screenshot.png',
+ 'mime_type' => 'image/png',
+ 'disk' => 's3',
+ 'size' => 1024,
+ 'manipulations' => [],
+ 'custom_properties' => ['sha1' => sha1(uniqid()), 'legacy_path' => $legacyPath],
+ 'generated_conversions' => [],
+ 'responsive_images' => [],
+ ]);
+}
+
+it('syncs legacy screenshot fields when a screenshot becomes primary', function () {
+ // ARRANGE
+ $game = Game::factory()->create([
+ 'system_id' => System::factory(),
+ 'image_ingame_asset_path' => '/Images/000002.png',
+ ]);
+
+ $media = createMediaForGame($game, '/Images/099999.png');
+
+ $screenshot = GameScreenshot::factory()->for($game)->ingame()->create([
+ 'media_id' => $media->id,
+ 'is_primary' => false,
+ ]);
+
+ // ACT
+ $screenshot->update(['is_primary' => true]);
+
+ // ASSERT
+ expect($game->fresh()->image_ingame_asset_path)->toEqual('/Images/099999.png');
+});
+
+it('does not sync legacy fields when a non-primary screenshot is saved', function () {
+ // ARRANGE
+ $game = Game::factory()->create([
+ 'system_id' => System::factory(),
+ 'image_ingame_asset_path' => '/Images/000002.png',
+ ]);
+
+ $media = createMediaForGame($game, '/Images/099999.png');
+
+ $screenshot = GameScreenshot::factory()->for($game)->ingame()->create([
+ 'media_id' => $media->id,
+ 'is_primary' => false,
+ ]);
+
+ // ACT
+ $screenshot->update(['description' => 'Updated description']);
+
+ // ASSERT
+ expect($game->fresh()->image_ingame_asset_path)->toEqual('/Images/000002.png');
+});
+
+it('promotes the next approved screenshot when the primary is deleted', function () {
+ // ARRANGE
+ $game = Game::factory()->create([
+ 'system_id' => System::factory(),
+ 'image_ingame_asset_path' => '/Images/000002.png',
+ ]);
+
+ $media1 = createMediaForGame($game, '/Images/099998.png');
+ $media2 = createMediaForGame($game, '/Images/099999.png');
+
+ $primary = GameScreenshot::factory()->for($game)->ingame()->primary()->create([
+ 'media_id' => $media1->id,
+ 'order_column' => 1,
+ ]);
+
+ $next = GameScreenshot::factory()->for($game)->ingame()->create([
+ 'media_id' => $media2->id,
+ 'is_primary' => false,
+ 'order_column' => 2,
+ ]);
+
+ // ACT
+ $primary->delete();
+
+ // ASSERT
+ expect($next->fresh()->is_primary)->toBeTrue();
+});
+
+it('resets to placeholder when the last screenshot of a type is deleted', function () {
+ // ARRANGE
+ $game = Game::factory()->create([
+ 'system_id' => System::factory(),
+ 'image_ingame_asset_path' => '/Images/099999.png',
+ ]);
+
+ $media = createMediaForGame($game, '/Images/099999.png');
+
+ $screenshot = GameScreenshot::factory()->for($game)->ingame()->primary()->create([
+ 'media_id' => $media->id,
+ ]);
+
+ // ACT
+ $screenshot->delete();
+
+ // ASSERT
+ expect($game->fresh()->image_ingame_asset_path)->toEqual('/Images/000002.png');
+});