SPT Semvar & Automatic Resolution

This update gives mod versions a supported SPT version field that accepts a semantic version. The latest supported SPT version will be automatically resolved based on the semvar.

Next up: I need to update the ModVersion to SptVersion relationship to be a many-to-many and expand the resolution to resolve multiple versions.
This commit is contained in:
Refringe 2024-08-22 17:04:07 -04:00
parent d1bfdf5424
commit db578071e4
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
44 changed files with 901 additions and 278 deletions

View File

@ -31,18 +31,6 @@ DB_PASSWORD=password
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_0900_ai_ci
# This is only needed if you are running the `artisan app:import-hub` command.
# For normal development you should just seed the database with fake data by
# running the command: `php artisan migrate:fresh --seed`
DB_HUB_CONNECTION=mysql
DB_HUB_HOST=
DB_HUB_PORT=
DB_HUB_DATABASE=
DB_HUB_USERNAME=
DB_HUB_PASSWORD=
DB_HUB_CHARSET=utf8mb4
DB_HUB_COLLATION=utf8mb4_0900_ai_ci
SESSION_DRIVER=redis
SESSION_CONNECTION=default
SESSION_LIFETIME=120
@ -86,3 +74,18 @@ OCTANE_SERVER=swoole
OCTANE_HTTPS=false
SAIL_XDEBUG_MODE=develop,debug,coverage
# Everything below is only needed if you are running the `artisan app:import-hub` command.
# For normal development you should just seed the database with fake data by
# running the command: `php artisan migrate:fresh --seed`
DB_HUB_CONNECTION=mysql
DB_HUB_HOST=
DB_HUB_PORT=
DB_HUB_DATABASE=
DB_HUB_USERNAME=
DB_HUB_PASSWORD=
DB_HUB_CHARSET=utf8mb4
DB_HUB_COLLATION=utf8mb4_0900_ai_ci
GITEA_DOMAIN=
GITEA_TOKEN=

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
22

View File

@ -2,10 +2,10 @@
namespace App\Console\Commands;
use App\Jobs\ImportHubData;
use App\Jobs\ImportHubDataJob;
use Illuminate\Console\Command;
class ImportHub extends Command
class ImportHubCommand extends Command
{
protected $signature = 'app:import-hub';
@ -13,8 +13,7 @@ class ImportHub extends Command
public function handle(): void
{
// Add the ImportHubData job to the queue.
ImportHubData::dispatch()->onQueue('long');
ImportHubDataJob::dispatch()->onQueue('long');
$this->info('The import job has been added to the queue.');
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ResolveDependenciesJob;
use App\Jobs\ResolveSptVersionsJob;
use Illuminate\Console\Command;
class ResolveVersionsCommand extends Command
{
protected $signature = 'app:resolve-versions';
protected $description = 'Resolve SPT and dependency versions for all mods.';
public function handle(): void
{
ResolveSptVersionsJob::dispatch()->onQueue('long');
ResolveDependenciesJob::dispatch()->onQueue('long');
$this->info('The import job has been added to the queue.');
}
}

View File

@ -5,7 +5,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
class SearchSync extends Command
class SearchSyncCommand extends Command
{
protected $signature = 'app:search-sync';

View File

@ -6,7 +6,7 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
class UploadAssets extends Command
class UploadAssetsCommand extends Command
{
protected $signature = 'app:upload-assets';

View File

@ -4,7 +4,4 @@ namespace App\Exceptions;
use Exception;
class CircularDependencyException extends Exception
{
protected $message = 'Circular dependency detected.';
}
class CircularDependencyException extends Exception {}

View File

@ -4,7 +4,4 @@ namespace App\Exceptions;
use Exception;
class InvalidVersionNumberException extends Exception
{
protected $message = 'The version number is an invalid semantic version.';
}
class InvalidVersionNumberException extends Exception {}

View File

@ -45,7 +45,7 @@ class ModController extends Controller
$this->authorize('view', $mod);
$latestVersion = $mod->versions->sortByDesc('version')->first();
$latestVersion = $mod->versions->first();
return view('mod.show', compact(['mod', 'latestVersion']));
}

View File

@ -31,8 +31,7 @@ class ModFilter
{
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
->withTotalDownloads()
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
->whereHas('latestVersion');
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']);
}
/**
@ -98,9 +97,10 @@ class ModFilter
*/
private function sptVersion(array $versions): Builder
{
return $this->builder->withWhereHas('latestVersion.sptVersion', function ($query) use ($versions) {
$query->whereIn('version', $versions);
$query->orderByDesc('version');
return $this->builder->whereHas('latestVersion', function ($query) use ($versions) {
$query->whereHas('sptVersion', function ($query) use ($versions) {
$query->whereIn('version', $versions);
});
});
}
}

View File

@ -28,7 +28,7 @@ use Illuminate\Support\Str;
use League\HTMLToMarkdown\HtmlConverter;
use Stevebauman\Purify\Facades\Purify;
class ImportHubData implements ShouldBeUnique, ShouldQueue
class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@ -42,6 +42,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
$this->bringFileContentLocal();
$this->bringFileVersionLabelsLocal();
$this->bringFileVersionContentLocal();
$this->bringSptVersionTagsLocal();
// Begin to import the data into the permanent local database tables.
$this->importUsers();
@ -53,9 +54,8 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
// Ensure that we've disconnected from the Hub database, clearing temporary tables.
DB::connection('mysql_hub')->disconnect();
// Re-sync search.
Artisan::call('app:search-sync');
Artisan::call('app:resolve-versions');
Artisan::call('cache:clear');
}
@ -70,19 +70,24 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
avatarExtension VARCHAR(255),
userID INT,
fileHash VARCHAR(255)
)');
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::connection('mysql_hub')
->table('wcf1_user_avatar')
->orderBy('avatarID')
->chunk(200, function ($avatars) {
$insertData = [];
foreach ($avatars as $avatar) {
DB::table('temp_user_avatar')->insert([
$insertData[] = [
'avatarID' => (int) $avatar->avatarID,
'avatarExtension' => $avatar->avatarExtension,
'userID' => (int) $avatar->userID,
'fileHash' => $avatar->fileHash,
]);
];
}
if ($insertData) {
DB::table('temp_user_avatar')->insert($insertData);
}
});
}
@ -93,17 +98,25 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileAuthorsLocal(): void
{
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_author');
DB::statement('CREATE TEMPORARY TABLE temp_file_author (fileID INT, userID INT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::statement('CREATE TEMPORARY TABLE temp_file_author (
fileID INT,
userID INT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::connection('mysql_hub')
->table('filebase1_file_author')
->orderBy('fileID')
->chunk(200, function ($relationships) {
$insertData = [];
foreach ($relationships as $relationship) {
DB::table('temp_file_author')->insert([
$insertData[] = [
'fileID' => (int) $relationship->fileID,
'userID' => (int) $relationship->userID,
]);
];
}
if ($insertData) {
DB::table('temp_file_author')->insert($insertData);
}
});
}
@ -114,18 +127,27 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileOptionsLocal(): void
{
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values');
DB::statement('CREATE TEMPORARY TABLE temp_file_option_values (fileID INT, optionID INT, optionValue VARCHAR(255)) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::statement('CREATE TEMPORARY TABLE temp_file_option_values (
fileID INT,
optionID INT,
optionValue VARCHAR(255)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::connection('mysql_hub')
->table('filebase1_file_option_value')
->orderBy('fileID')
->chunk(200, function ($options) {
$insertData = [];
foreach ($options as $option) {
DB::table('temp_file_option_values')->insert([
$insertData[] = [
'fileID' => (int) $option->fileID,
'optionID' => (int) $option->optionID,
'optionValue' => $option->optionValue,
]);
];
}
if ($insertData) {
DB::table('temp_file_option_values')->insert($insertData);
}
});
}
@ -136,19 +158,29 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileContentLocal(): void
{
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_content');
DB::statement('CREATE TEMPORARY TABLE temp_file_content (fileID INT, subject VARCHAR(255), teaser VARCHAR(255), message LONGTEXT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::statement('CREATE TEMPORARY TABLE temp_file_content (
fileID INT,
subject VARCHAR(255),
teaser VARCHAR(255),
message LONGTEXT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::connection('mysql_hub')
->table('filebase1_file_content')
->orderBy('fileID')
->chunk(200, function ($contents) {
$insertData = [];
foreach ($contents as $content) {
DB::table('temp_file_content')->insert([
$insertData[] = [
'fileID' => (int) $content->fileID,
'subject' => $content->subject,
'teaser' => $content->teaser,
'message' => $content->message,
]);
];
}
if ($insertData) {
DB::table('temp_file_content')->insert($insertData);
}
});
}
@ -159,18 +191,26 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileVersionLabelsLocal(): void
{
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels');
DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels (labelID INT, objectID INT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels (
labelID INT,
objectID INT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::connection('mysql_hub')
->table('wcf1_label_object')
->where('objectTypeID', 387)
->orderBy('labelID')
->chunk(200, function ($options) {
$insertData = [];
foreach ($options as $option) {
DB::table('temp_file_version_labels')->insert([
$insertData[] = [
'labelID' => (int) $option->labelID,
'objectID' => (int) $option->objectID,
]);
];
}
if ($insertData) {
DB::table('temp_file_version_labels')->insert($insertData);
}
});
}
@ -181,17 +221,54 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
protected function bringFileVersionContentLocal(): void
{
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content');
DB::statement('CREATE TEMPORARY TABLE temp_file_version_content (versionID INT, description TEXT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::statement('CREATE TEMPORARY TABLE temp_file_version_content (
versionID INT,
description TEXT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::connection('mysql_hub')
->table('filebase1_file_version_content')
->orderBy('versionID')
->chunk(200, function ($options) {
$insertData = [];
foreach ($options as $option) {
DB::table('temp_file_version_content')->insert([
$insertData[] = [
'versionID' => (int) $option->versionID,
'description' => $option->description,
]);
];
}
if ($insertData) {
DB::table('temp_file_version_content')->insert($insertData);
}
});
}
private function bringSptVersionTagsLocal(): void
{
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_spt_version_tags');
DB::statement('CREATE TEMPORARY TABLE temp_spt_version_tags (
hub_id INT,
version VARCHAR(255),
color_class VARCHAR(255)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::connection('mysql_hub')
->table('wcf1_label')
->where('groupID', 1)
->orderBy('labelID')
->chunk(100, function (Collection $versions) {
$insertData = [];
foreach ($versions as $version) {
$insertData[] = [
'hub_id' => (int) $version->labelID,
'version' => $version->label,
'color_class' => $version->cssClassName,
];
}
if ($insertData) {
DB::table('temp_spt_version_tags')->insert($insertData);
}
});
}
@ -531,40 +608,142 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
/**
* Import the SPT versions from the Hub database to the local database.
*
* @throws Exception
*/
protected function importSptVersions(): void
{
DB::connection('mysql_hub')
->table('wcf1_label')
->where('groupID', 1)
->chunkById(100, function (Collection $versions) {
$insertData = [];
foreach ($versions as $version) {
$insertData[] = [
'hub_id' => (int) $version->labelID,
'version' => $version->label,
'color_class' => $this->translateColour($version->cssClassName),
];
}
$domain = config('services.gitea.domain');
$token = config('services.gitea.token');
if (! empty($insertData)) {
DB::table('spt_versions')->upsert($insertData, ['hub_id'], ['version', 'color_class']);
}
}, 'labelID');
if (empty($domain) || empty($token)) {
return;
}
$url = "{$domain}/api/v1/repos/SPT/Stable-releases/releases?draft=false&pre-release=false&token={$token}";
$response = json_decode(file_get_contents($url), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('JSON Decode Error: '.json_last_error_msg());
}
if (empty($response)) {
throw new Exception('No version data found in the API response.');
}
$latestVersion = $this->getLatestVersion($response);
$insertData = [];
foreach ($response as $version) {
$insertData[] = [
'version' => $version['tag_name'],
'link' => $version['html_url'],
'color_class' => $this->detectVersionColor($version['tag_name'], $latestVersion),
'created_at' => Carbon::parse($version['created_at'], 'UTC'),
'updated_at' => Carbon::parse($version['created_at'], 'UTC'),
];
}
// Add a fake 0.0.0 version for outdated mods.
$insertData[] = [
'version' => '0.0.0',
'link' => '',
'color_class' => 'black',
'created_at' => Carbon::now('UTC'),
'updated_at' => Carbon::now('UTC'),
];
// Upsert won't work here. Do it manually. :(
foreach ($insertData as $data) {
$existingVersion = SptVersion::where('version', $data['version'])->first();
if ($existingVersion) {
$existingVersion->update([
'link' => $data['link'],
'color_class' => $data['color_class'],
'created_at' => $data['created_at'],
'updated_at' => $data['updated_at'],
]);
} else {
SptVersion::create($data);
}
}
}
/**
* Translate the colour class from the Hub database to the local database.
* Get the latest current version from the response data.
*/
protected function translateColour(string $colour = ''): string
protected function getLatestVersion(array $versions): string
{
return match ($colour) {
'green' => 'green',
'slightly-outdated' => 'lime',
'yellow' => 'yellow',
'red' => 'red',
default => 'gray',
};
$semanticVersions = array_map(
fn ($version) => $this->extractSemanticVersion($version['tag_name']),
$versions
);
usort($semanticVersions, 'version_compare');
return end($semanticVersions);
}
/**
* Extract the last semantic version from a string.
* If the version has no patch number, return it as `~<major>.<minor>.0`.
*/
protected function extractSemanticVersion(string $versionString, bool $appendPatch = false): ?string
{
// Match both two-part and three-part semantic versions
preg_match_all('/\b\d+\.\d+(?:\.\d+)?\b/', $versionString, $matches);
// Get the last version found, if any
$version = end($matches[0]) ?: null;
if (! $appendPatch) {
return $version;
}
// If version is two-part (e.g., "3.9"), add ".0" and prefix with "~"
if ($version && preg_match('/^\d+\.\d+$/', $version)) {
$version = '~'.$version.'.0';
}
return $version;
}
/**
* Translate the version string into a color class.
*/
protected function detectVersionColor(string $versionString, string $currentVersion): string
{
$version = $this->extractSemanticVersion($versionString);
if (! $version) {
return 'gray';
}
if ($version === '0.0.0') {
return 'black';
}
[$currentMajor, $currentMinor] = explode('.', $currentVersion);
[$major, $minor] = explode('.', $version);
$currentMajor = (int) $currentMajor;
$currentMinor = (int) $currentMinor;
$major = (int) $major;
$minor = (int) $minor;
if ($major == $currentMajor) {
$difference = $currentMinor - $minor;
return match ($difference) {
0 => 'green',
1 => 'lime',
2 => 'yellow',
3 => 'red',
default => 'gray',
};
}
return 'gray';
}
/**
@ -748,17 +927,22 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
continue;
}
// Fetch the version string using the labelID from the hub.
$sptVersionTemp = DB::table('temp_spt_version_tags')->where('hub_id', $versionLabel->labelID)->value('version');
$sptVersionConstraint = $this->extractSemanticVersion($sptVersionTemp, appendPatch: true) ?? '0.0.0';
$insertData[] = [
'hub_id' => (int) $version->versionID,
'mod_id' => $modId,
'version' => $version->versionNumber,
'version' => $this->extractSemanticVersion($version->versionNumber) ?? '0.0.0',
'description' => $this->cleanHubContent($versionContent->description ?? ''),
'link' => $version->downloadURL,
'spt_version_id' => SptVersion::whereHubId($versionLabel->labelID)->value('id'),
'spt_version_constraint' => $sptVersionConstraint,
'resolved_spt_version_id' => null,
'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '',
'downloads' => max((int) $version->downloads, 0), // At least 0.
'disabled' => (bool) $version->isDisabled,
'published_at' => Carbon::parse($version->uploadTime, 'UTC'),
'published_at' => $sptVersionConstraint === '0.0.0' ? null : Carbon::parse($version->uploadTime, 'UTC'),
'created_at' => Carbon::parse($version->uploadTime, 'UTC'),
'updated_at' => Carbon::parse($version->uploadTime, 'UTC'),
];
@ -770,7 +954,8 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
'version',
'description',
'link',
'spt_version_id',
'spt_version_constraint',
'resolved_spt_version_id',
'virus_total_link',
'downloads',
'published_at',
@ -793,6 +978,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content');
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels');
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content');
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_spt_version_tags');
// Close the connections. This should drop the temporary tables as well, but I like to be explicit.
DB::connection('mysql_hub')->disconnect();

View File

@ -0,0 +1,29 @@
<?php
namespace App\Jobs;
use App\Models\ModVersion;
use App\Services\DependencyVersionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ResolveDependenciesJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Resolve the SPT versions for each of the mod versions.
*/
public function handle(): void
{
$dependencyVersionService = new DependencyVersionService;
foreach (ModVersion::all() as $modVersion) {
$dependencyVersionService->resolve($modVersion);
}
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Jobs;
use App\Models\ModVersion;
use App\Services\SptVersionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ResolveSptVersionsJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Resolve the SPT versions for each of the mod versions.
*/
public function handle(): void
{
$sptVersionService = new SptVersionService;
foreach (ModVersion::all() as $modVersion) {
$sptVersionService->resolve($modVersion);
}
}
}

View File

@ -49,7 +49,10 @@ class Index extends Component
*/
public function mount(): void
{
// TODO: This should be updated to only pull versions that have mods associated with them.
// To do this, the ModVersion to SptVersion relationship needs to be converted to a many-to-many relationship. Ugh.
$this->availableSptVersions = SptVersion::select(['id', 'version', 'color_class'])->orderByDesc('version')->get();
$this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray();
}
@ -75,7 +78,7 @@ class Index extends Component
'order' => $this->order,
'sptVersion' => $this->sptVersion,
];
$mods = (new ModFilter($filters))->apply()->paginate(24);
$mods = (new ModFilter($filters))->apply()->paginate(16);
return view('livewire.mod.index', compact('mods'));
}

View File

@ -58,9 +58,16 @@ class Mod extends Model
/**
* The relationship between a mod and its versions.
*/
public function versions(): HasMany
public function versions(bool $resolvedOnly = true): HasMany
{
return $this->hasMany(ModVersion::class)->orderByDesc('version');
$relation = $this->hasMany(ModVersion::class)
->orderByDesc('version');
if ($resolvedOnly) {
$relation->whereNotNull('resolved_spt_version_id');
}
return $relation;
}
/**
@ -77,10 +84,16 @@ class Mod extends Model
/**
* The relationship between a mod and its last updated version.
*/
public function lastUpdatedVersion(): HasOne
public function lastUpdatedVersion(bool $resolvedOnly = true): HasOne
{
return $this->hasOne(ModVersion::class)
$relation = $this->hasOne(ModVersion::class)
->orderByDesc('updated_at');
if ($resolvedOnly) {
$relation->whereNotNull('resolved_spt_version_id');
}
return $relation;
}
/**
@ -108,12 +121,18 @@ class Mod extends Model
/**
* The relationship to the latest mod version, dictated by the mod version number.
*/
public function latestVersion(): HasOne
public function latestVersion(bool $resolvedOnly = true): HasOne
{
return $this->hasOne(ModVersion::class)
$relation = $this->hasOne(ModVersion::class)
->orderByDesc('version')
->orderByDesc('updated_at')
->take(1);
if ($resolvedOnly) {
$relation->whereNotNull('resolved_spt_version_id');
}
return $relation;
}
/**

View File

@ -26,7 +26,7 @@ class ModDependency extends Model
}
/**
* The relationship between a mod dependency and mod.
* The relationship between the mod dependency and the mod that is depended on.
*/
public function dependencyMod(): BelongsTo
{

View File

@ -39,9 +39,15 @@ class ModVersion extends Model
/**
* The relationship between a mod version and its dependencies.
*/
public function dependencies(): HasMany
public function dependencies(bool $resolvedOnly = true): HasMany
{
return $this->hasMany(ModDependency::class);
$relation = $this->hasMany(ModDependency::class);
if ($resolvedOnly) {
$relation->whereNotNull('resolved_version_id');
}
return $relation;
}
/**
@ -49,6 +55,6 @@ class ModVersion extends Model
*/
public function sptVersion(): BelongsTo
{
return $this->belongsTo(SptVersion::class);
return $this->belongsTo(SptVersion::class, 'resolved_spt_version_id');
}
}

View File

@ -36,22 +36,21 @@ class SptVersion extends Model
}
try {
$currentMinorVersion = $this->extractMinorVersion($this->version);
$latestMinorVersion = $this->extractMinorVersion($latestVersion->version);
[$currentMajor, $currentMinor, $currentPatch] = $this->extractVersionParts($this->version);
[$latestMajor, $latestMinor, $latestPatch] = $this->extractVersionParts($latestVersion->version);
} catch (InvalidVersionNumberException $e) {
// Could not parse a semver version number.
return false;
}
return $currentMinorVersion === $latestMinorVersion;
return $currentMajor == $latestMajor && $currentMinor === $latestMinor;
}
/**
* Extract the minor version from a full version string.
* Extract the version components from a full version string.
*
* @throws InvalidVersionNumberException
*/
private function extractMinorVersion(string $version): int
private function extractVersionParts(string $version): array
{
// Remove everything from the version string except the numbers and dots.
$version = preg_replace('/[^0-9.]/', '', $version);
@ -63,7 +62,10 @@ class SptVersion extends Model
$parts = explode('.', $version);
// Return the minor version part.
return (int) $parts[1];
return [
(int) $parts[0],
(int) $parts[1],
(int) $parts[2],
];
}
}

View File

@ -2,32 +2,50 @@
namespace App\Observers;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Services\ModVersionService;
use App\Services\DependencyVersionService;
class ModDependencyObserver
{
protected ModVersionService $modVersionService;
protected DependencyVersionService $dependencyVersionService;
public function __construct(ModVersionService $modVersionService)
public function __construct(DependencyVersionService $dependencyVersionService)
{
$this->modVersionService = $modVersionService;
$this->dependencyVersionService = $dependencyVersionService;
}
/**
* Handle the ModDependency "saved" event.
*
* @throws CircularDependencyException
*/
public function saved(ModDependency $modDependency): void
{
$modVersion = ModVersion::find($modDependency->mod_version_id);
if ($modVersion) {
$this->modVersionService->resolveDependencies($modVersion);
}
$this->resolveDependencyVersion($modDependency);
}
public function deleted(ModDependency $modDependency): void
/**
* Resolve the ModDependency's dependencies.
*
* @throws CircularDependencyException
*/
public function resolveDependencyVersion(ModDependency $modDependency): void
{
$modVersion = ModVersion::find($modDependency->mod_version_id);
if ($modVersion) {
$this->modVersionService->resolveDependencies($modVersion);
$this->dependencyVersionService->resolve($modVersion);
}
}
/**
* Handle the ModDependency "deleted" event.
*
* @throws CircularDependencyException
*/
public function deleted(ModDependency $modDependency): void
{
$this->resolveDependencyVersion($modDependency);
}
}

View File

@ -2,32 +2,57 @@
namespace App\Observers;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Services\ModVersionService;
use App\Services\DependencyVersionService;
use App\Services\SptVersionService;
class ModVersionObserver
{
protected ModVersionService $modVersionService;
protected DependencyVersionService $dependencyVersionService;
public function __construct(ModVersionService $modVersionService)
{
$this->modVersionService = $modVersionService;
protected SptVersionService $sptVersionService;
public function __construct(
DependencyVersionService $dependencyVersionService,
SptVersionService $sptVersionService,
) {
$this->dependencyVersionService = $dependencyVersionService;
$this->sptVersionService = $sptVersionService;
}
/**
* Handle the ModVersion "saved" event.
*
* @throws CircularDependencyException
*/
public function saved(ModVersion $modVersion): void
{
$dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get();
foreach ($dependencies as $dependency) {
$this->modVersionService->resolveDependencies($dependency->modVersion);
}
$this->resolveDependencyVersion($modVersion);
$this->sptVersionService->resolve($modVersion);
}
public function deleted(ModVersion $modVersion): void
/**
* Resolve the ModVersion's dependencies.
*
* @throws CircularDependencyException
*/
private function resolveDependencyVersion(ModVersion $modVersion): void
{
$dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get();
foreach ($dependencies as $dependency) {
$this->modVersionService->resolveDependencies($dependency->modVersion);
$this->dependencyVersionService->resolve($dependency->modVersion);
}
}
/**
* Handle the ModVersion "deleted" event.
*
* @throws CircularDependencyException
*/
public function deleted(ModVersion $modVersion): void
{
$this->resolveDependencyVersion($modVersion);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Observers;
use App\Models\ModVersion;
use App\Services\SptVersionService;
class SptVersionObserver
{
protected SptVersionService $sptVersionService;
public function __construct(SptVersionService $sptVersionService)
{
$this->sptVersionService = $sptVersionService;
}
/**
* Handle the SptVersion "saved" event.
*/
public function saved(): void
{
$this->resolveSptVersion();
}
/**
* Resolve the SptVersion's dependencies.
*/
private function resolveSptVersion(): void
{
$modVersions = ModVersion::all();
foreach ($modVersions as $modVersion) {
$this->sptVersionService->resolve($modVersion);
}
}
/**
* Handle the SptVersion "deleted" event.
*/
public function deleted(): void
{
$this->resolveSptVersion();
}
}

View File

@ -4,9 +4,11 @@ namespace App\Providers;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Models\SptVersion;
use App\Models\User;
use App\Observers\ModDependencyObserver;
use App\Observers\ModVersionObserver;
use App\Observers\SptVersionObserver;
use App\Services\LatestSptVersionService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
@ -36,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
// Register observers.
ModVersion::observe(ModVersionObserver::class);
ModDependency::observe(ModDependencyObserver::class);
SptVersion::observe(SptVersionObserver::class);
// This gate determines who can access the Pulse dashboard.
Gate::define('viewPulse', function (User $user) {

View File

@ -0,0 +1,119 @@
<?php
namespace App\Services;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion;
use Composer\Semver\Semver;
class DependencyVersionService
{
/**
* Keep track of visited versions to avoid resolving them again.
*/
protected array $visited = [];
/**
* Keep track of the current path in the depth-first search.
*/
protected array $stack = [];
/**
* Resolve dependencies for the given mod version.
*
* @throws CircularDependencyException
*/
public function resolve(ModVersion $modVersion): array
{
$this->visited = [];
$this->stack = [];
// Store the resolved versions for each dependency.
$resolvedVersions = [];
// Start the recursive depth-first search to resolve dependencies.
$this->processDependencies($modVersion, $resolvedVersions);
return $resolvedVersions;
}
/**
* Perform a depth-first search to resolve dependencies for the given mod version.
*
* @throws CircularDependencyException
*/
protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void
{
// Detect circular dependencies
if (in_array($modVersion->id, $this->stack)) {
throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}");
}
// Skip already processed versions
if (in_array($modVersion->id, $this->visited)) {
return;
}
// Mark the current version
$this->visited[] = $modVersion->id;
$this->stack[] = $modVersion->id;
// Get the dependencies for the current mod version.
$dependencies = $modVersion->dependencies(resolvedOnly: false)->get();
foreach ($dependencies as $dependency) {
// Resolve the latest mod version ID that satisfies the version constraint on the mod version dependency.
$resolvedId = $this->resolveDependency($dependency);
// Update the resolved version ID for the dependency if it has changed.
// Do it "quietly" to avoid triggering the observer again.
if ($dependency->resolved_version_id !== $resolvedId) {
$dependency->updateQuietly(['resolved_version_id' => $resolvedId]);
}
// At this point, the dependency has been resolved (or not) and we can add it to the resolved versions to
// avoid resolving it again in the future and to help with circular dependency detection.
$resolvedVersions[$dependency->id] = $resolvedId ? ModVersion::find($resolvedId) : null;
// Recursively process the resolved dependency.
if ($resolvedId) {
$nextModVersion = ModVersion::find($resolvedId);
if ($nextModVersion) {
$this->processDependencies($nextModVersion, $resolvedVersions);
}
}
}
// Remove the current version from the stack now that we have processed all its dependencies.
array_pop($this->stack);
}
/**
* Resolve the latest mod version ID that satisfies the version constraint on the mod version dependency.
*/
protected function resolveDependency(ModDependency $dependency): ?int
{
$dependencyModVersions = $dependency->dependencyMod->versions(resolvedOnly: false);
// There are no mod versions for the dependency mod.
if ($dependencyModVersions->doesntExist()) {
return null;
}
$availableVersions = $dependencyModVersions->pluck('id', 'version')->toArray();
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint);
// There are no mod versions that satisfy the version constraint.
if (empty($satisfyingVersions)) {
return null;
}
// Sort the satisfying versions in descending order using the version_compare function.
usort($satisfyingVersions, 'version_compare');
$satisfyingVersions = array_reverse($satisfyingVersions);
// Return the latest (highest version number) satisfying version.
return $availableVersions[$satisfyingVersions[0]];
}
}

View File

@ -4,6 +4,10 @@ namespace App\Services;
use App\Models\SptVersion;
/**
* This class is responsible for fetching the latest SPT version. It's registered as a singleton in the service
* container so that the latest version is only fetched once per request.
*/
class LatestSptVersionService
{
protected ?SptVersion $version = null;

View File

@ -1,99 +0,0 @@
<?php
namespace App\Services;
use App\Exceptions\CircularDependencyException;
use App\Models\ModDependency;
use App\Models\ModVersion;
use Composer\Semver\Semver;
use Illuminate\Database\Eloquent\Collection;
class ModVersionService
{
protected array $visited = [];
protected array $stack = [];
/**
* Resolve dependencies for the given mod version.
*
* @throws CircularDependencyException
*/
public function resolveDependencies(ModVersion $modVersion): array
{
$resolvedVersions = [];
$this->visited = [];
$this->stack = [];
$this->processDependencies($modVersion, $resolvedVersions);
return $resolvedVersions;
}
/**
* Perform a depth-first search to resolve dependencies for the given mod version.
*
* @throws CircularDependencyException
*/
protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void
{
if (in_array($modVersion->id, $this->stack)) {
throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}");
}
if (in_array($modVersion->id, $this->visited)) {
return; // Skip already processed versions
}
$this->visited[] = $modVersion->id;
$this->stack[] = $modVersion->id;
/** @var Collection|ModDependency[] $dependencies */
$dependencies = $this->getDependencies($modVersion);
foreach ($dependencies as $dependency) {
$resolvedVersionId = $this->resolveVersionIdForDependency($dependency);
if ($dependency->resolved_version_id !== $resolvedVersionId) {
$dependency->updateQuietly(['resolved_version_id' => $resolvedVersionId]);
}
$resolvedVersions[$dependency->id] = $resolvedVersionId ? ModVersion::find($resolvedVersionId) : null;
if ($resolvedVersionId) {
$nextModVersion = ModVersion::find($resolvedVersionId);
if ($nextModVersion) {
$this->processDependencies($nextModVersion, $resolvedVersions);
}
}
}
array_pop($this->stack);
}
/**
* Get the dependencies for the given mod version.
*/
protected function getDependencies(ModVersion $modVersion): Collection
{
return $modVersion->dependencies()->with(['dependencyMod.versions'])->get();
}
/**
* Resolve the latest version ID that satisfies the version constraint on given dependency.
*/
protected function resolveVersionIdForDependency(ModDependency $dependency): ?int
{
$mod = $dependency->dependencyMod;
if (! $mod || $mod->versions->isEmpty()) {
return null;
}
$availableVersions = $mod->versions->pluck('id', 'version')->toArray();
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint);
// Versions are sorted in descending order by default. Take the first key (the latest version) using `reset()`.
return $satisfyingVersions ? $availableVersions[reset($satisfyingVersions)] : null;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Services;
use App\Models\ModVersion;
use App\Models\SptVersion;
use Composer\Semver\Semver;
class SptVersionService
{
/**
* Resolve dependencies for the given mod version.
*/
public function resolve(ModVersion $modVersion): void
{
$modVersion->resolved_spt_version_id = $this->satisfyconstraint($modVersion);
$modVersion->saveQuietly();
}
/**
* Satisfies the version constraint of a given ModVersion. Returns the ID of the satisfying SptVersion.
*/
private function satisfyConstraint(ModVersion $modVersion): ?int
{
$availableVersions = SptVersion::query()
->orderBy('version', 'desc')
->pluck('id', 'version')
->toArray();
$satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $modVersion->spt_version_constraint);
if (empty($satisfyingVersions)) {
return null;
}
// Ensure the satisfying versions are sorted in descending order to get the latest version
usort($satisfyingVersions, 'version_compare');
$satisfyingVersions = array_reverse($satisfyingVersions);
// Return the ID of the latest satisfying version
return $availableVersions[$satisfyingVersions[0]];
}
}

View File

@ -30,7 +30,6 @@ class ModListSection extends Component
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
->withTotalDownloads()
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
->whereHas('latestVersion')
->where('featured', true)
->latest()
->limit(6)
@ -44,7 +43,6 @@ class ModListSection extends Component
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at'])
->withTotalDownloads()
->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name'])
->whereHas('latestVersion')
->latest()
->limit(6)
->get();
@ -57,7 +55,6 @@ class ModListSection extends Component
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured'])
->withTotalDownloads()
->with(['lastUpdatedVersion', 'lastUpdatedVersion.sptVersion', 'users:id,name'])
->whereHas('lastUpdatedVersion')
->orderByDesc(
ModVersion::select('updated_at')
->whereColumn('mod_id', 'mods.id')

View File

@ -35,4 +35,9 @@ return [
],
],
'gitea' => [
'domain' => env('GITEA_DOMAIN', ''),
'token' => env('GITEA_TOKEN', ''),
],
];

View File

@ -17,9 +17,19 @@ class ModDependencyFactory extends Factory
return [
'mod_version_id' => ModVersion::factory(),
'dependency_mod_id' => Mod::factory(),
'version_constraint' => '^'.$this->faker->numerify('#.#.#'),
'version_constraint' => fake()->numerify($this->generateVersionConstraint()),
'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
'updated_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
];
}
/**
* This method generates a random version constraint from a predefined set of options.
*/
private function generateVersionConstraint(): string
{
$versionConstraints = ['*', '^1.#.#', '>=2.#.#', '~1.#.#', '>=1.2.# <2.#.#'];
return $versionConstraints[array_rand($versionConstraints)];
}
}

View File

@ -14,12 +14,15 @@ class ModVersionFactory extends Factory
public function definition(): array
{
$constraint = fake()->numerify($this->generateVersionConstraint());
return [
'mod_id' => Mod::factory(),
'version' => fake()->numerify('#.#.#'),
'description' => fake()->text(),
'link' => fake()->url(),
'spt_version_id' => SptVersion::factory(),
'spt_version_constraint' => $constraint,
'resolved_spt_version_id' => null,
'virus_total_link' => fake()->url(),
'downloads' => fake()->randomNumber(),
'published_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)),
@ -28,6 +31,31 @@ class ModVersionFactory extends Factory
];
}
/**
* This method generates a random version constraint from a predefined set of options.
*/
private function generateVersionConstraint(): string
{
$versionConstraints = ['*', '^1.#.#', '>=2.#.#', '~1.#.#'];
return $versionConstraints[array_rand($versionConstraints)];
}
/**
* Indicate that the mod version should have a resolved SPT version.
*/
public function sptVersionResolved(): static
{
$constraint = fake()->numerify('#.#.#');
return $this->state(fn (array $attributes) => [
'spt_version_constraint' => $constraint,
'resolved_spt_version_id' => SptVersion::factory()->create([
'version' => $constraint,
]),
]);
}
/**
* Indicate that the mod version should be disabled.
*/

View File

@ -13,8 +13,9 @@ class SptVersionFactory extends Factory
public function definition(): array
{
return [
'version' => $this->faker->numerify('SPT 1.#.#'),
'version' => $this->faker->numerify('#.#.#'),
'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']),
'link' => $this->faker->url,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];

View File

@ -15,6 +15,7 @@ return new class extends Migration
->default(null)
->unique();
$table->string('version');
$table->string('link');
$table->string('color_class');
$table->softDeletes();
$table->timestamps();

View File

@ -1,7 +1,6 @@
<?php
use App\Models\Mod;
use App\Models\SptVersion;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@ -23,9 +22,9 @@ return new class extends Migration
$table->string('version');
$table->longText('description');
$table->string('link');
$table->foreignIdFor(SptVersion::class)
$table->string('spt_version_constraint');
$table->foreignId('resolved_spt_version_id')
->nullable()
->default(null)
->constrained('spt_versions')
->nullOnDelete()
->cascadeOnUpdate();

View File

@ -18,7 +18,7 @@ return new class extends Migration
->constrained('mods')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->string('version_constraint'); // e.g., ^1.0.1
$table->string('version_constraint');
$table->foreignId('resolved_version_id')
->nullable()
->constrained('mod_versions')

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('failed_jobs');
}
};

View File

@ -2,7 +2,6 @@
namespace Database\Seeders;
use App\Exceptions\CircularDependencyException;
use App\Models\License;
use App\Models\Mod;
use App\Models\ModDependency;
@ -20,7 +19,7 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
// Create a few SPT versions.
$spt_versions = SptVersion::factory(10)->create();
$spt_versions = SptVersion::factory(30)->create();
// Create some code licenses.
$licenses = License::factory(10)->create();
@ -39,40 +38,26 @@ class DatabaseSeeder extends Seeder
// Add 100 users.
$users = User::factory(100)->create();
// Add 200 mods, assigning them to the users we just created.
// Add 300 mods, assigning them to the users we just created.
$allUsers = $users->merge([$administrator, $moderator]);
$mods = Mod::factory(200)->recycle([$licenses])->create();
$mods = Mod::factory(300)->recycle([$licenses])->create();
foreach ($mods as $mod) {
$userIds = $allUsers->random(rand(1, 3))->pluck('id')->toArray();
$mod->users()->attach($userIds);
}
// Add 1000 mod versions, assigning them to the mods we just created.
$modVersions = ModVersion::factory(1000)->recycle([$mods, $spt_versions])->create();
// Add 3000 mod versions, assigning them to the mods we just created.
$modVersions = ModVersion::factory(3000)->recycle([$mods, $spt_versions])->create();
// Add ModDependencies to a subset of ModVersions.
foreach ($modVersions as $modVersion) {
$hasDependencies = rand(0, 100) < 30; // 30% chance to have dependencies
if ($hasDependencies) {
$numDependencies = rand(1, 3); // 1 to 3 dependencies
$dependencyMods = $mods->random($numDependencies);
$dependencyMods = $mods->random(rand(1, 3)); // 1 to 3 dependencies
foreach ($dependencyMods as $dependencyMod) {
try {
ModDependency::factory()->recycle([$modVersion, $dependencyMod])->create([
'version_constraint' => $this->generateVersionConstraint(),
]);
} catch (CircularDependencyException $e) {
continue;
}
ModDependency::factory()->recycle([$modVersion, $dependencyMod])->create();
}
}
}
}
private function generateVersionConstraint(): string
{
$versionConstraints = ['*', '^1.0.0', '>=2.0.0', '~1.1.0', '>=1.2.0 <2.0.0'];
return $versionConstraints[array_rand($versionConstraints)];
}
}

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "html",
"name": "forge",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@ -1,9 +1,13 @@
includes:
- ./vendor/larastan/larastan/extension.neon
parameters:
level: 6
paths:
- app/
- app
- bootstrap
- config
- database
- lang
- routes
excludePaths:
analyseAndScan:
- tests/
level: 4
- tests/**/*

View File

@ -1,4 +1,4 @@
@props(['mods, versionScope, title'])
@props(['mods', 'versionScope', 'title'])
<div class="mx-auto max-w-7xl px-4 pt-16 sm:px-6 lg:px-8">
{{--

View File

@ -156,7 +156,7 @@
@endforeach
</div>
@else
<div class="text-center">
<div class="text-center text-gray-700 dark:text-gray-300">
<p>{{ __('There were no mods found with those filters applied. ') }}</p>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mx-auto">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.182 16.318A4.486 4.486 0 0 0 12.016 15a4.486 4.486 0 0 0-3.198 1.318M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />

View File

@ -1,8 +1,10 @@
<?php
use App\Console\Commands\ImportHub;
use App\Console\Commands\ImportHubCommand;
use App\Console\Commands\ResolveVersionsCommand;
use Illuminate\Support\Facades\Schedule;
Schedule::command(ImportHub::class)->hourly();
Schedule::command(ImportHubCommand::class)->hourly();
Schedule::command(ResolveVersionsCommand::class)->hourlyAt(30);
Schedule::command('horizon:snapshot')->everyFiveMinutes();

View File

@ -13,13 +13,13 @@ it('resolves mod version dependency when mod version is created', function () {
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
ModVersion::factory()->recycle($modB)->create(['version' => '1.0.0']);
ModVersion::factory()->recycle($modB)->create(['version' => '1.1.0']);
ModVersion::factory()->recycle($modB)->create(['version' => '1.1.1']);
ModVersion::factory()->recycle($modB)->create(['version' => '2.0.0']);
// Create versions for Mod A that depends on Mod B
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modAv1 = ModVersion::factory()->recycle($modA)->create(['version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^1.0.0',
]);
@ -74,7 +74,7 @@ it('resolves mod version dependency when mod version is deleted', function () {
$modDependency->refresh();
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
// Update the mod B version
// Delete the mod B version
$modBv3->delete();
$modDependency->refresh();
@ -155,6 +155,34 @@ it('resolves mod version dependency with complex semantic version constraint', f
expect($modDependency->resolvedVersion->version)->toBe('1.1.1');
});
it('resolves previously unresolved mod version dependency after semantic version constraint is updated', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);
// Create versions for Mod B
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']);
ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.1']);
// Create version for Mod A that depends on Mod B, but no version satisfies the constraint.
$modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']);
$modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([
'version_constraint' => '^3.0.0',
]);
$modDependency->refresh();
expect($modDependency->resolved_version_id)->toBeNull();
// Update the dependency version constraint
$modDependency->update(['version_constraint' => '^2.0.0']);
$modDependency->refresh();
expect($modDependency->resolved_version_id)->not->toBeNull();
expect($modDependency->resolvedVersion->version)->toBe('2.0.1');
});
it('resolves null when no mod versions are available', function () {
$modA = Mod::factory()->create(['name' => 'Mod A']);
$modB = Mod::factory()->create(['name' => 'Mod B']);

View File

@ -6,11 +6,28 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows the latest version on the mod detail page', function () {
it('can retrieve all unresolved versions', function () {
// Create a mod instance
$mod = Mod::factory()->create();
ModVersion::factory(5)->recycle($mod)->create();
// Create 5 mod versions with specified versions
ModVersion::all()->each(function (ModVersion $modVersion) {
$modVersion->resolved_spt_version_id = null;
$modVersion->saveQuietly();
});
$unresolvedMix = $mod->versions(resolvedOnly: false);
$unresolvedMix->each(function (ModVersion $modVersion) {
expect($modVersion)->toBeInstanceOf(ModVersion::class)
->and($modVersion->resolved_spt_version_id)->toBeNull();
});
expect($unresolvedMix->count())->toBe(5)
->and($mod->versions->count())->toBe(0);
});
it('shows the latest version on the mod detail page', function () {
$versions = [
'1.0.0',
'1.1.0',
@ -18,21 +35,16 @@ it('shows the latest version on the mod detail page', function () {
'2.0.0',
'2.1.0',
];
// get the highest version in the array
$latestVersion = max($versions);
$mod = Mod::factory()->create();
foreach ($versions as $version) {
ModVersion::factory()->create([
'mod_id' => $mod->id,
'version' => $version,
]);
ModVersion::factory()->sptVersionResolved()->recycle($mod)->create(['version' => $version]);
}
// Make a request to the mod's detail URL
$response = $this->get($mod->detailUrl());
$this->assertEquals('2.1.0', $latestVersion);
expect($latestVersion)->toBe('2.1.0');
// Assert the latest version is next to the mod's name
$response->assertSeeInOrder(explode(' ', "$mod->name $latestVersion"));

View File

@ -1,11 +1,81 @@
<?php
use App\Models\ModVersion;
use App\Models\SptVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
uses(RefreshDatabase::class);
it('resolves spt version when mod version is created', function () {
SptVersion::factory()->create(['version' => '1.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']);
SptVersion::factory()->create(['version' => '1.2.0']);
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
$modVersion->refresh();
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.1.1');
});
it('resolves spt version when constraint is updated', function () {
SptVersion::factory()->create(['version' => '1.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']);
SptVersion::factory()->create(['version' => '1.2.0']);
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.1.1');
$modVersion->spt_version_constraint = '~1.2.0';
$modVersion->save();
$modVersion->refresh();
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.2.0');
});
it('resolves spt version when spt version is created', function () {
SptVersion::factory()->create(['version' => '1.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']);
SptVersion::factory()->create(['version' => '1.2.0']);
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.1.1');
SptVersion::factory()->create(['version' => '1.1.2']);
$modVersion->refresh();
expect($modVersion->sptVersion->version)->toBe('1.1.2');
});
it('resolves spt version when spt version is deleted', function () {
SptVersion::factory()->create(['version' => '1.0.0']);
SptVersion::factory()->create(['version' => '1.1.0']);
SptVersion::factory()->create(['version' => '1.1.1']);
$sptVersion = SptVersion::factory()->create(['version' => '1.1.2']);
$modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']);
expect($modVersion->resolved_spt_version_id)->not->toBeNull();
expect($modVersion->sptVersion->version)->toBe('1.1.2');
$sptVersion->delete();
$modVersion->refresh();
expect($modVersion->sptVersion->version)->toBe('1.1.1');
});
it('includes only published mod versions', function () {
$publishedMod = ModVersion::factory()->create([
'published_at' => Carbon::now()->subDay(),