Skip to content
Open
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
153 changes: 137 additions & 16 deletions minecraft-modrinth/src/Services/MinecraftModrinthService.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,7 @@ class MinecraftModrinthService

public function getMinecraftVersion(Server $server): ?string
{
$version = $server->variables()->where(fn ($builder) => $builder->where('env_variable', 'MINECRAFT_VERSION')->orWhere('env_variable', 'MC_VERSION'))->first()?->server_value;

if (!$version || $version === 'latest') {
return $this->getLatestMinecraftVersion();
}

return $version;
return $this->getConfiguredMinecraftVersion($server) ?? $this->getLatestMinecraftVersion();
}

public function getLatestMinecraftVersion(): ?string
Expand All @@ -55,6 +49,71 @@ public function getLatestMinecraftVersion(): ?string
});
}

/**
* The most recent Modrinth release tags, newest first. Used instead of a single exact
* "latest" version when a server has no explicit Minecraft version configured: plugin
* authors often lag behind re-tagging support for the very newest release even though
* nothing in the plugin actually changed, so a single exact match hides plugins that
* work fine on it. This has no effect once a server sets an explicit version, since that
* is treated as an exact requirement instead.
*
* @return string[]
*/
protected function getRecentMinecraftVersions(): array
{
return cache()->remember('modrinth:recent_minecraft_versions', now()->addHour(), function () {
try {
/** @var array<int, mixed> $versions */
$versions = Http::asJson()
->timeout(5)
->connectTimeout(5)
->throw()
->get('https://api.modrinth.com/v2/tag/game_version')
->json();

return collect($versions)
->filter(fn ($version) => $version['version_type'] === 'release')
->take(5)
->pluck('version')
->all();
} catch (Exception $exception) {
report($exception);

return [];
}
});
}

protected function getConfiguredMinecraftVersion(Server $server): ?string
{
$version = $server->variables()->where(fn ($builder) => $builder->where('env_variable', 'MINECRAFT_VERSION')->orWhere('env_variable', 'MC_VERSION'))->first()?->server_value;

return ($version && $version !== 'latest') ? $version : null;
}

/**
* Minecraft versions to filter search/version-list results by. An explicit server
* version is an exact requirement (a single value), but a server without one falls
* back to a small window of the most recent releases rather than only the single
* newest, for the reason explained on getRecentMinecraftVersions().
*
* @return string[]
*/
protected function getMinecraftVersionsForFiltering(Server $server): array
{
$configured = $this->getConfiguredMinecraftVersion($server);
if ($configured) {
return [$configured];
}

$recent = $this->getRecentMinecraftVersions();
if (!empty($recent)) {
return $recent;
}

return array_filter([$this->getLatestMinecraftVersion()]);
}

/** @return array{icon: string, name: string, supported_project_types: string[], display_name: string}|null */
public function getLoaderFromServer(Server $server): ?array
{
Expand Down Expand Up @@ -104,7 +163,52 @@ public function getLoaders(): array
});
}

/** @return array{hits: array<int, array<string, mixed>>, total_hits: int} */
/**
* Loaders that can also run plugins/mods published only for an upstream loader they
* are backwards-compatible with, e.g. a Paper server can run plain Spigot/Bukkit plugins.
* Only one direction: a plugin published for the fork isn't guaranteed to run on the
* upstream loader, so the reverse mapping is intentionally not added.
*
* @return string[]
*/
protected function getCompatibleLoaders(string $loader): array
{
return match ($loader) {
'spigot' => ['spigot', 'bukkit'],
'paper' => ['paper', 'spigot', 'bukkit'],
'purpur' => ['purpur', 'paper', 'spigot', 'bukkit'],
'folia' => ['folia', 'paper', 'spigot', 'bukkit'],
'waterfall' => ['waterfall', 'bungeecord'],
'quilt' => ['quilt', 'fabric'],
default => [$loader],
};
}

/**
* Proxy loaders aren't tied to a specific Minecraft version the way a server is: they
* relay the protocol for whatever version the backend servers run, so a plugin's declared
* Minecraft game versions on Modrinth mostly just reflect whenever it was last published,
* not what it's actually compatible with. Filtering those by an exact game version hides
* older but still working proxy plugins, so the version filter is skipped for them.
*/
protected function isProxyLoader(string $loader): bool
{
return in_array($loader, ['velocity', 'bungeecord', 'waterfall'], true);
}

/**
* Modrinth's own "project_type" field (mod/plugin/resourcepack/...) is whatever the
* author picked when they first created the project; a Bukkit-family project can be
* stored as "mod" even though it only has paper/spigot/purpur versions and Modrinth's
* own site lists it under /plugin/ (it decides that split by loader, not this field).
* Filtering strictly by our own Mod/Plugin enum value against that field hid projects
* like https://modrinth.com/plugin/excellenteconomy this way. The loader/category facet
* already discriminates mod-loader projects (fabric/forge/...) from plugin-loader ones
* (paper/spigot/...), so project_type is only kept as a loose safety net excluding
* unrelated types like resourcepacks/shaders/datapacks, not as the mod/plugin split.
*
* @return array{hits: array<int, array<string, mixed>>, total_hits: int}
*/
public function getProjects(Server $server, ModrinthProjectType $modrinthProjectType, int $page = 1, ?string $search = null): array
{
$modrinthProjectType = $modrinthProjectType->value;
Expand All @@ -120,10 +224,19 @@ public function getProjects(Server $server, ModrinthProjectType $modrinthProject
$minecraftVersion = $this->getMinecraftVersion($server);
$minecraftLoader = $minecraftLoader['name'];

$loaderFacets = implode(',', array_map(fn ($loader) => "\"categories:$loader\"", $this->getCompatibleLoaders($minecraftLoader)));

$facetGroups = ["[$loaderFacets]"];
if (!$this->isProxyLoader($minecraftLoader)) {
$versionFacets = implode(',', array_map(fn ($version) => "\"versions:$version\"", $this->getMinecraftVersionsForFiltering($server)));
$facetGroups[] = "[$versionFacets]";
}
$facetGroups[] = '["project_type:mod","project_type:plugin"]';

$data = [
'offset' => ($page - 1) * 20,
'limit' => 20,
'facets' => "[[\"categories:$minecraftLoader\"],[\"versions:$minecraftVersion\"],[\"project_type:{$modrinthProjectType}\"]]",
'facets' => '['.implode(',', $facetGroups).']',
];

$key = "modrinth_projects:{$modrinthProjectType}:$minecraftVersion:$minecraftLoader:$page";
Expand Down Expand Up @@ -261,13 +374,21 @@ protected function getVersionsCacheKey(string $projectId, ?string $minecraftVers
return "modrinth_versions:$projectId:$minecraftVersion:$minecraftLoader";
}

/** @return array{game_versions: string, loaders: string} */
protected function getVersionsQuery(?string $minecraftVersion, string $minecraftLoader): array
/** @return array{game_versions?: string, loaders: string} */
protected function getVersionsQuery(Server $server, string $minecraftLoader): array
{
return [
'game_versions' => "[\"$minecraftVersion\"]",
'loaders' => "[\"$minecraftLoader\"]",
$loaders = implode(',', array_map(fn ($loader) => "\"$loader\"", $this->getCompatibleLoaders($minecraftLoader)));

$query = [
'loaders' => "[$loaders]",
];

if (!$this->isProxyLoader($minecraftLoader)) {
$versions = implode(',', array_map(fn ($version) => "\"$version\"", $this->getMinecraftVersionsForFiltering($server)));
$query['game_versions'] = "[$versions]";
}

return $query;
}

/** @param array<int, mixed> $versions */
Expand Down Expand Up @@ -311,7 +432,7 @@ public function getProjectVersions(string $projectId, Server $server): array
->timeout(5)
->connectTimeout(5)
->throw()
->get("https://api.modrinth.com/v2/project/$projectId/version", $this->getVersionsQuery($minecraftVersion, $minecraftLoader))
->get("https://api.modrinth.com/v2/project/$projectId/version", $this->getVersionsQuery($server, $minecraftLoader))
->json();
} catch (Exception $exception) {
report($exception);
Expand Down Expand Up @@ -350,7 +471,7 @@ public function getProjectVersionsBulk(array $projectIds, Server $server): array

$minecraftVersion = $this->getMinecraftVersion($server);
$minecraftLoader = $minecraftLoader['name'];
$query = $this->getVersionsQuery($minecraftVersion, $minecraftLoader);
$query = $this->getVersionsQuery($server, $minecraftLoader);

$results = [];
$missing = [];
Expand Down
Loading