Skip to content

Commit 416e115

Browse files
authored
fix: allow collab claim creation when at max primary claims (RetroAchievements#4772)
https://discord.com/channels/310192285306454017/310195377993416714/1492957030934122791 Developers are currently prevented from creating new collab claims when they have all 4 primary claims used.
1 parent 21ff699 commit 416e115

8 files changed

Lines changed: 173 additions & 17 deletions

File tree

app/Policies/AchievementSetClaimPolicy.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ public function create(User $user, ?Game $game = null): bool
6262
return true;
6363
}
6464

65+
// If another user has a primary claim, collaboration claims don't count against the cap.
66+
$activePrimaryClaimByOtherUser = $game->achievementSetClaims()
67+
->active()
68+
->primaryClaim()
69+
->where('user_id', '!=', $user->id)
70+
->exists();
71+
72+
if ($activePrimaryClaimByOtherUser) {
73+
return true;
74+
}
75+
6576
// Determine max claims based on role.
6677
$maxClaims = AchievementSetClaim::getMaxClaimsForUser($user);
6778

resources/js/features/games/components/AchievementSetEmptyState/ClaimActionButton/ClaimActionButton.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,25 @@ describe('Component: ClaimActionButton', () => {
121121
expect(screen.getByRole('button', { name: /collaborate/i })).toBeVisible();
122122
});
123123

124+
it('given the user has no claims remaining but can collaborate, shows the collaboration claim button', () => {
125+
// ARRANGE
126+
render(<ClaimActionButton />, {
127+
pageProps: {
128+
auth: { user: createAuthenticatedUser({ roles: ['developer'] }) },
129+
backingGame: createGame({ forumTopicId: 12345 }),
130+
claimData: createGamePageClaimData({
131+
numClaimsRemaining: 0,
132+
numUnresolvedTickets: 0,
133+
wouldBeCollaboration: true,
134+
}),
135+
},
136+
});
137+
138+
// ASSERT
139+
expect(screen.getByRole('button', { name: /collaborate/i })).toBeVisible();
140+
expect(screen.queryByText(/used all your achievement set claims/i)).not.toBeInTheDocument();
141+
});
142+
124143
it('given the user can make a new claim and all conditions are met, shows the real claim button', () => {
125144
// ARRANGE
126145
render(<ClaimActionButton />, {

resources/js/features/games/components/AchievementSetEmptyState/ClaimActionButton/ClaimActionButton.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { usePageProps } from '@/common/hooks/usePageProps';
1212
import { cn } from '@/common/utils/cn';
1313
import { ClaimConfirmationDialog } from '@/features/games/components/ClaimConfirmationDialog';
14+
import { getCanCreateClaim } from '@/features/games/utils/getCanCreateClaim';
1415

1516
export const ClaimActionButton: FC = () => {
1617
const { auth, backingGame, claimData } = usePageProps<App.Platform.Data.GameShowPageProps>();
@@ -42,7 +43,7 @@ export const ClaimActionButton: FC = () => {
4243
return null;
4344
}
4445

45-
if (!claimData?.numClaimsRemaining && !claimData?.isSoleAuthor) {
46+
if (!getCanCreateClaim(claimData)) {
4647
return (
4748
<BaseTooltip>
4849
<BaseTooltipTrigger>
@@ -68,7 +69,7 @@ export const ClaimActionButton: FC = () => {
6869
);
6970
}
7071

71-
if (claimData.wouldBeCollaboration) {
72+
if (claimData?.wouldBeCollaboration) {
7273
return (
7374
<ClaimConfirmationDialog
7475
data-testid="claim-button"

resources/js/features/games/components/GameSidebarFullWidthButtons/SidebarContributeLinks/SidebarDevelopmentSection/SidebarClaimButtons/SidebarClaimButtons.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ describe('Component: SidebarClaimButtons', () => {
2929
expect(container).toBeTruthy();
3030
});
3131

32+
it('given the user does not have a claim role, does not show the create claim button', () => {
33+
// ARRANGE
34+
render(<SidebarClaimButtons />, {
35+
pageProps: {
36+
achievementSetClaims: [],
37+
auth: { user: createAuthenticatedUser({ roles: [] }) },
38+
backingGame: createGame(),
39+
claimData: createGamePageClaimData({ numClaimsRemaining: 1, userClaim: null }),
40+
game: createGame({ gameAchievementSets: [] }),
41+
targetAchievementSetId: null,
42+
},
43+
});
44+
45+
// ASSERT
46+
expect(screen.queryByRole('button', { name: /create new claim/i })).not.toBeInTheDocument();
47+
});
48+
3249
it('given there are claims in review, does not render anything', () => {
3350
// ARRANGE
3451
render(<SidebarClaimButtons />, {
@@ -137,6 +154,27 @@ describe('Component: SidebarClaimButtons', () => {
137154
expect(screen.getByRole('button', { name: /create new claim/i })).toBeVisible();
138155
});
139156

157+
it('given the user has no claims remaining but can collaborate, shows the collaboration claim button', () => {
158+
// ARRANGE
159+
render(<SidebarClaimButtons />, {
160+
pageProps: {
161+
achievementSetClaims: [],
162+
auth: { user: createAuthenticatedUser({ roles: ['developer'] }) },
163+
backingGame: createGame(),
164+
claimData: createGamePageClaimData({
165+
numClaimsRemaining: 0,
166+
userClaim: null,
167+
wouldBeCollaboration: true,
168+
}),
169+
game: createGame({ gameAchievementSets: [] }),
170+
targetAchievementSetId: null,
171+
},
172+
});
173+
174+
// ASSERT
175+
expect(screen.getByRole('button', { name: /create new collaboration claim/i })).toBeVisible();
176+
});
177+
140178
it('given the user has a completable claim, shows the complete claim button', () => {
141179
// ARRANGE
142180
render(<SidebarClaimButtons />, {

resources/js/features/games/components/GameSidebarFullWidthButtons/SidebarContributeLinks/SidebarDevelopmentSection/SidebarClaimButtons/SidebarClaimButtons.tsx

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import { LuFlagTriangleRight } from 'react-icons/lu';
55
import { PlayableSidebarButton } from '@/common/components/PlayableSidebarButton';
66
import { usePageProps } from '@/common/hooks/usePageProps';
77
import { ClaimConfirmationDialog } from '@/features/games/components/ClaimConfirmationDialog';
8+
import { useCanShowCreateClaimButton } from '@/features/games/hooks/useCanShowCreateClaimButton';
89
import { getAllPageAchievements } from '@/features/games/utils/getAllPageAchievements';
910

1011
export const SidebarClaimButtons: FC = () => {
11-
const { achievementSetClaims, auth, backingGame, claimData, game, targetAchievementSetId } =
12+
const { achievementSetClaims, backingGame, claimData, game, targetAchievementSetId } =
1213
usePageProps<App.Platform.Data.GameShowPageProps>();
1314
const { t } = useTranslation();
1415

16+
const canShowCreateClaimButton = useCanShowCreateClaimButton();
17+
1518
const areAnyClaimsInReview = achievementSetClaims.some((c) => c.status === 'in_review');
1619
if (areAnyClaimsInReview) {
1720
return null;
@@ -23,20 +26,6 @@ export const SidebarClaimButtons: FC = () => {
2326
);
2427
const wouldBeRevisionClaim = allPageAchievements.length > 0;
2528

26-
const hasClaimRole =
27-
auth?.user.roles.includes('developer-junior') || auth?.user.roles.includes('developer');
28-
29-
const isJuniorDev = auth?.user.roles.includes('developer-junior');
30-
31-
// Junior developers can only create claims on games with forum topics.
32-
const isBlockedByMissingForumTopic = isJuniorDev && !backingGame.forumTopicId;
33-
34-
// `claimData?.isSoleAuthor` means devs can reclaim their own sets to fix something.
35-
const hasClaimsRemaining = claimData?.numClaimsRemaining || claimData?.isSoleAuthor;
36-
37-
const canShowCreateClaimButton =
38-
hasClaimRole && hasClaimsRemaining && !claimData.userClaim && !isBlockedByMissingForumTopic;
39-
4029
return (
4130
<>
4231
{canShowCreateClaimButton ? (
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { usePageProps } from '@/common/hooks/usePageProps';
2+
import { UserRole } from '@/common/utils/generatedAppConstants';
3+
4+
import { getCanCreateClaim } from '../utils/getCanCreateClaim';
5+
6+
export function useCanShowCreateClaimButton(): boolean {
7+
const { auth, backingGame, claimData } = usePageProps<App.Platform.Data.GameShowPageProps>();
8+
9+
const user = auth?.user;
10+
if (!user || claimData?.userClaim) {
11+
return false;
12+
}
13+
14+
const isJuniorDev = user.roles.includes(UserRole.DEVELOPER_JUNIOR);
15+
const hasClaimRole = isJuniorDev || user.roles.includes(UserRole.DEVELOPER);
16+
if (!hasClaimRole) {
17+
return false;
18+
}
19+
20+
// Junior devs can only create claims on games with official forum topics.
21+
if (isJuniorDev && !backingGame.forumTopicId) {
22+
return false;
23+
}
24+
25+
return getCanCreateClaim(claimData);
26+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export function getCanCreateClaim(claimData: App.Platform.Data.GamePageClaimData | null): boolean {
2+
const hasPrimarySlotAvailable = (claimData?.numClaimsRemaining ?? 0) > 0;
3+
const canCreateWithoutPrimarySlot = claimData?.isSoleAuthor || claimData?.wouldBeCollaboration;
4+
5+
return hasPrimarySlotAvailable || !!canCreateWithoutPrimarySlot;
6+
}

tests/Feature/Community/Controllers/AchievementSetClaimControllerTest.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,72 @@ public function testCollaborationClaimAndPrimaryDrop(): void
490490
$this->assertEquals(0, $collabClaim->extensions_count);
491491
}
492492

493+
public function testCollaborationClaimCanBeCreatedWhenDeveloperHasMaxPrimaryClaims(): void
494+
{
495+
$this->seed(RolesTableSeeder::class);
496+
497+
/** @var User $primaryDeveloper */
498+
$primaryDeveloper = User::factory()->create();
499+
$primaryDeveloper->assignRole(Role::DEVELOPER);
500+
501+
/** @var User $collaborator */
502+
$collaborator = User::factory()->create();
503+
$collaborator->assignRole(Role::DEVELOPER);
504+
505+
Forum::factory()->create(['id' => 10, 'title' => 'Default']);
506+
507+
/** @var Game $targetGame */
508+
$targetGame = $this->seedGame(withHash: false);
509+
510+
// another developer already owns the active primary claim on the target game
511+
$primaryClaimDate = Carbon::now()->startOfSecond();
512+
Carbon::setTestNow($primaryClaimDate);
513+
514+
$this->actingAs($primaryDeveloper)->postJson(route('achievement-set-claim.create', $targetGame->id));
515+
$targetGame->refresh();
516+
517+
// fill the collaborator's four primary claim slots
518+
for ($i = 0; $i < 4; $i++) {
519+
/** @var Game $claimedGame */
520+
$claimedGame = $this->seedGame(withHash: false);
521+
$claimedGame->forum_topic_id = $targetGame->forum_topic_id;
522+
$claimedGame->save();
523+
524+
Carbon::setTestNow($primaryClaimDate->clone()->addHours($i + 1));
525+
Session::flush();
526+
527+
$this->actingAs($collaborator)->postJson(route('achievement-set-claim.create', $claimedGame->id));
528+
}
529+
530+
// with no primary slots remaining, the collaborator can still join this claimed game
531+
$collaborationDate = $primaryClaimDate->clone()->addHours(5);
532+
Carbon::setTestNow($collaborationDate);
533+
Session::flush();
534+
535+
$response = $this->actingAs($collaborator)->postJson(route('achievement-set-claim.create', $targetGame->id));
536+
537+
$response->assertStatus(302);
538+
$response->assertRedirect('/');
539+
$response->assertSessionHas('success', 'Claim created successfully');
540+
541+
$collabClaim = $targetGame->achievementSetClaims()->where('user_id', $collaborator->id)->first();
542+
$this->assertNotNull($collabClaim);
543+
$this->assertEquals($collaborator->id, $collabClaim->user_id);
544+
$this->assertEquals($targetGame->id, $collabClaim->game_id);
545+
$this->assertEquals(ClaimType::Collaboration, $collabClaim->claim_type);
546+
$this->assertEquals(ClaimSetType::NewSet, $collabClaim->set_type);
547+
$this->assertEquals(ClaimStatus::Active, $collabClaim->status);
548+
$this->assertEquals(ClaimSpecial::None, $collabClaim->special_type);
549+
$this->assertEquals($collaborationDate->clone()->addMonths(3), $collabClaim->finished_at);
550+
551+
$activePrimaryClaims = $collaborator->achievementSetClaims()
552+
->active()
553+
->primaryClaim()
554+
->count();
555+
556+
$this->assertEquals(4, $activePrimaryClaims);
557+
}
558+
493559
public function testCollaborationClaimAndPrimaryComplete(): void
494560
{
495561
$this->seed(RolesTableSeeder::class);

0 commit comments

Comments
 (0)