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
1 change: 1 addition & 0 deletions app/Api/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ private function apiRoutes(): void
->readOnly()
->relationships(function ($relationships) {
$relationships->hasMany('playerGames')->readOnly();
$relationships->hasMany('playerAchievementSets')->readOnly();
});
});
});
Expand Down
6 changes: 2 additions & 4 deletions app/Api/V2/Achievements/AchievementSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ class AchievementSchema extends Schema
/**
* Relationships that should always be eager loaded.
*/
protected array $with = [
'achievementSet.gameAchievementSets',
];
protected array $with = ['achievementSet.gameAchievementSets'];

/**
* Default pagination parameters when client doesn't provide any.
Expand Down Expand Up @@ -104,7 +102,7 @@ public function filters(): array
return [
WhereIdIn::make($this),
Scope::make('state', 'withState'),
Scope::make('gameId', 'forGame'),
Scope::make('gameId', 'forGameId'),
WhereIn::make('type')->delimiter(','),
];
}
Expand Down
97 changes: 97 additions & 0 deletions app/Api/V2/PlayerAchievementSets/PlayerAchievementSetResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace App\Api\V2\PlayerAchievementSets;

use App\Api\V2\BaseJsonApiResource;
use App\Models\PlayerAchievementSet;
use App\Platform\Enums\AchievementSetType;
use Illuminate\Http\Request;
use LaravelJsonApi\Core\Document\Links;

/**
* @property PlayerAchievementSet $resource
*/
class PlayerAchievementSetResource extends BaseJsonApiResource
{
/**
* Get the resource's attributes.
*
* @param Request|null $request
*/
public function attributes($request): iterable
{
return [
'achievementsUnlocked' => $this->resource->achievements_unlocked,
'achievementsUnlockedHardcore' => $this->resource->achievements_unlocked_hardcore,

'points' => $this->resource->points,
'pointsHardcore' => $this->resource->points_hardcore,
'pointsWeighted' => $this->resource->points_weighted,

'completionPercentage' => $this->resource->completion_percentage,
'completionPercentageHardcore' => $this->resource->completion_percentage_hardcore,

'lastUnlockAt' => $this->resource->last_unlock_at,
'lastUnlockHardcoreAt' => $this->resource->last_unlock_hardcore_at,
'completedAt' => $this->resource->completed_at,
'completedHardcoreAt' => $this->resource->completed_hardcore_at,

'timeTakenSeconds' => $this->resource->time_taken,
'timeTakenHardcoreSeconds' => $this->resource->time_taken_hardcore,

'setContext' => $this->getSetContext(),
];
}

/**
* Get the resource's relationships.
*
* @param Request|null $request
*/
public function relationships($request): iterable
{
return [
'achievementSet' => $this->relation('achievementSet')->withoutLinks(),
'game' => $this->relation('game')->withoutLinks(),
];
}

/**
* @param Request|null $request
*/
public function links($request): Links
{
// Player achievement sets have no dedicated web URL.
return new Links();
}

/**
* Get game/type pairs so callers know the game context
* without needing to include the full game resource.
*
* @return array<array{gameId: int, type: string}>
*/
private function getSetContext(): array
{
$gameAchievementSets = $this->resource->achievementSet->gameAchievementSets;

$hasCoreAttachment = $gameAchievementSets->contains(
fn ($gas) => $gas->type === AchievementSetType::Core
);
$hasNonCoreAttachment = $gameAchievementSets->contains(
fn ($gas) => $gas->type !== AchievementSetType::Core
);

$setsToInclude = ($hasCoreAttachment && $hasNonCoreAttachment)
? $gameAchievementSets->filter(fn ($gas) => $gas->type !== AchievementSetType::Core)
: $gameAchievementSets;

return $setsToInclude->map(fn ($gas) => [
'achievementSetId' => $gas->achievement_set_id,
'gameId' => $gas->game_id,
'type' => $gas->type instanceof AchievementSetType ? $gas->type->value : $gas->type,
])->values()->all();
}
}
106 changes: 106 additions & 0 deletions app/Api/V2/PlayerAchievementSets/PlayerAchievementSetSchema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace App\Api\V2\PlayerAchievementSets;

use App\Models\PlayerAchievementSet;
use LaravelJsonApi\Eloquent\Contracts\Paginator;
use LaravelJsonApi\Eloquent\Fields\ArrayList;
use LaravelJsonApi\Eloquent\Fields\DateTime;
use LaravelJsonApi\Eloquent\Fields\ID;
use LaravelJsonApi\Eloquent\Fields\Number;
use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo;
use LaravelJsonApi\Eloquent\Fields\Relations\HasOneThrough;
use LaravelJsonApi\Eloquent\Filters\Scope;
use LaravelJsonApi\Eloquent\Filters\Where;
use LaravelJsonApi\Eloquent\Filters\WhereIdIn;
use LaravelJsonApi\Eloquent\Pagination\PagePagination;
use LaravelJsonApi\Eloquent\Schema;

class PlayerAchievementSetSchema extends Schema
{
/**
* The model the schema corresponds to.
*/
public static string $model = PlayerAchievementSet::class;

/**
* Relationships that should always be eager loaded.
*/
protected array $with = ['achievementSet.gameAchievementSets'];

/**
* Default pagination parameters when client doesn't provide any.
* This prevents unbounded result sets.
*/
protected ?array $defaultPagination = ['number' => 1];

/**
* Default sort order when client doesn't provide any.
* Shows sets with most recently unlocked achievements first.
*/
protected $defaultSort = '-lastUnlockAt';

/**
* Get the resource type.
*/
public static function type(): string
{
return 'player-achievement-sets';
}

/**
* Get the resource fields.
*/
public function fields(): array
{
return [
ID::make(),

Number::make('achievementsUnlocked', 'achievements_unlocked')->readOnly(),
Number::make('achievementsUnlockedHardcore', 'achievements_unlocked_hardcore')->readOnly(),

Number::make('points', 'points')->sortable()->readOnly(),
Number::make('pointsHardcore', 'points_hardcore')->sortable()->readOnly(),
Number::make('pointsWeighted', 'points_weighted')->sortable()->readOnly(),

Number::make('completionPercentage', 'completion_percentage')->sortable()->readOnly(),
Number::make('completionPercentageHardcore', 'completion_percentage_hardcore')->sortable()->readOnly(),

DateTime::make('lastUnlockAt', 'last_unlock_at')->sortable()->readOnly(),
DateTime::make('lastUnlockHardcoreAt', 'last_unlock_hardcore_at')->sortable()->readOnly(),
DateTime::make('completedAt', 'completed_at')->sortable()->readOnly(),
DateTime::make('completedHardcoreAt', 'completed_hardcore_at')->sortable()->readOnly(),

Number::make('timeTakenSeconds', 'time_taken')->sortable()->readOnly(),
Number::make('timeTakenHardcoreSeconds', 'time_taken_hardcore')->sortable()->readOnly(),

ArrayList::make('setContext')->readOnly(),

BelongsTo::make('achievementSet')->type('achievement-sets')->readOnly(),
HasOneThrough::make('game')->type('games'), // HasOneThrough is always implicitly ->readOnly()
];
}

/**
* Get the resource filters.
*/
public function filters(): array
{
return [
WhereIdIn::make($this),
Where::make('achievementSetId', 'achievement_set_id'),
Scope::make('gameId', 'forGameId'),
];
}

/**
* Get the resource paginator.
*/
public function pagination(): ?Paginator
{
return PagePagination::make()
->withDefaultPerPage(50);
}
}
7 changes: 4 additions & 3 deletions app/Api/V2/PlayerGames/PlayerGameResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ public function attributes($request): iterable
'beatenHardcoreAt' => $this->resource->beaten_hardcore_at,

// Time tracking.
'playtimeTotal' => $this->resource->playtime_total,
'timeToBeat' => $this->resource->time_to_beat,
'timeToBeatHardcore' => $this->resource->time_to_beat_hardcore,
'playtimeTotalSeconds' => $this->resource->playtime_total,
'timeToBeatSeconds' => $this->resource->time_to_beat,
'timeToBeatHardcoreSeconds' => $this->resource->time_to_beat_hardcore,
];
}

Expand All @@ -49,6 +49,7 @@ public function relationships($request): iterable
return [
'achievementSets' => $this->relation('achievementSets')->withoutLinks(),
'game' => $this->relation('game')->withoutLinks(),
'playerAchievementSets' => $this->relation('playerAchievementSets')->withoutLinks(),
];
}

Expand Down
11 changes: 5 additions & 6 deletions app/Api/V2/PlayerGames/PlayerGameSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use LaravelJsonApi\Eloquent\Fields\Number;
use LaravelJsonApi\Eloquent\Fields\Relations\BelongsTo;
use LaravelJsonApi\Eloquent\Fields\Relations\BelongsToMany;
use LaravelJsonApi\Eloquent\Fields\Relations\HasManyThrough;
use LaravelJsonApi\Eloquent\Filters\Where;
use LaravelJsonApi\Eloquent\Filters\WhereIdIn;
use LaravelJsonApi\Eloquent\Pagination\PagePagination;
Expand Down Expand Up @@ -73,16 +74,14 @@ public function fields(): array
DateTime::make('beatenHardcoreAt', 'beaten_hardcore_at')->readOnly(),

// Time tracking.
Number::make('playtimeTotal', 'playtime_total')->readOnly(),
Number::make('timeToBeat', 'time_to_beat')->readOnly(),
Number::make('timeToBeatHardcore', 'time_to_beat_hardcore')->readOnly(),
Number::make('playtimeTotalSeconds', 'playtime_total')->readOnly(),
Number::make('timeToBeatSeconds', 'time_to_beat')->readOnly(),
Number::make('timeToBeatHardcoreSeconds', 'time_to_beat_hardcore')->readOnly(),

// Relationships.
BelongsToMany::make('achievementSets')->type('achievement-sets')->readOnly(),
BelongsTo::make('game')->readOnly(),

// TODO add relationships
// - playerAchievementSets (HasMany PlayerAchievementSet) - per-set player progress
HasManyThrough::make('playerAchievementSets')->type('player-achievement-sets'),
];
}

Expand Down
1 change: 1 addition & 0 deletions app/Api/V2/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ protected function allSchemas(): array
Hubs\HubSchema::class,
LeaderboardEntries\LeaderboardEntrySchema::class,
Leaderboards\LeaderboardSchema::class,
PlayerAchievementSets\PlayerAchievementSetSchema::class,
PlayerGames\PlayerGameSchema::class,
Systems\SystemSchema::class,
Users\UserSchema::class,
Expand Down
2 changes: 1 addition & 1 deletion app/Api/V2/Users/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ public function attributes($request): iterable
public function relationships($request): iterable
{
return [
'playerAchievementSets' => $this->relation('playerAchievementSets')->withoutLinks(),
'playerGames' => $this->relation('playerGames')->withoutLinks(),

// TODO add relationships
// 'lastGame' => $this->relation('lastGame'),
// 'playerAchievementSets' => $this->relation('playerAchievementSets'),
// 'playerAchievements' => $this->relation('playerAchievements'),
// 'awards' => $this->relation('playerBadges'),
// 'following' => $this->relation('followedUsers'),
Expand Down
2 changes: 1 addition & 1 deletion app/Api/V2/Users/UserSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,11 @@ public function fields(): array
Str::make('visibleRole')->readOnly(),
ArrayList::make('displayableRoles')->readOnly(),

HasMany::make('playerAchievementSets')->type('player-achievement-sets')->cannotEagerLoad()->readOnly(),
HasMany::make('playerGames')->type('player-games')->cannotEagerLoad()->readOnly(),

// TODO add relationships and relationship endpoints
// - lastGame (BelongsTo Game)
// - playerAchievementSets (HasMany PlayerAchievementSet)
// - playerAchievements (HasMany PlayerAchievement)
// - awards (HasMany PlayerBadge)
// - following (BelongsToMany User) - users this user follows
Expand Down
2 changes: 1 addition & 1 deletion app/Models/Achievement.php
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ public function scopeWithUnlocksByUser(Builder $query, User $user): Builder
* @param Builder<Achievement> $query
* @return Builder<Achievement>
*/
public function scopeForGame(Builder $query, int $gameId): Builder
public function scopeForGameId(Builder $query, int $gameId): Builder
{
return $query->whereExists(function ($subQuery) use ($gameId) {
$subQuery->select(DB::raw(1))
Expand Down
40 changes: 40 additions & 0 deletions app/Models/PlayerAchievementSet.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@

namespace App\Models;

use App\Platform\Enums\AchievementSetType;
use App\Support\Database\Eloquent\BasePivot;
use Database\Factories\PlayerAchievementSetFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;

class PlayerAchievementSet extends BasePivot
{
/** @use HasFactory<PlayerAchievementSetFactory> */
use HasFactory;

protected $table = 'player_achievement_sets';

protected $casts = [
Expand All @@ -21,6 +29,11 @@ class PlayerAchievementSet extends BasePivot
'last_unlock_hardcore_at' => 'datetime',
];

protected static function newFactory(): PlayerAchievementSetFactory
{
return PlayerAchievementSetFactory::new();
}

// == accessors

// == mutators
Expand Down Expand Up @@ -60,5 +73,32 @@ public function player(): BelongsTo
return $this->user();
}

/**
* Prefer the non-core attachment so we return the
* parent game rather than the subset backing game.
*
* @return HasOneThrough<Game, GameAchievementSet, $this>
*/
public function game(): HasOneThrough
{
return $this->hasOneThrough(
Game::class,
GameAchievementSet::class,
'achievement_set_id', // FK on game_achievement_sets
'id', // FK on games (its primary key)
'achievement_set_id', // Local key
'game_id' // Local key
)->orderByRaw("CASE WHEN game_achievement_sets.type != ? THEN 0 ELSE 1 END", [AchievementSetType::Core->value]);
}

// == scopes

/**
* @param Builder<PlayerAchievementSet> $query
* @return Builder<PlayerAchievementSet>
*/
public function scopeForGameId(Builder $query, int $gameId): Builder
{
return $query->whereHas('game', fn (Builder $q) => $q->where('games.id', $gameId));
}
}
Loading