diff --git a/minecraft-modrinth/src/Services/MinecraftModrinthService.php b/minecraft-modrinth/src/Services/MinecraftModrinthService.php index 7db2b7f5..2810a150 100644 --- a/minecraft-modrinth/src/Services/MinecraftModrinthService.php +++ b/minecraft-modrinth/src/Services/MinecraftModrinthService.php @@ -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 @@ -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 $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 { @@ -104,7 +163,52 @@ public function getLoaders(): array }); } - /** @return array{hits: array>, 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>, total_hits: int} + */ public function getProjects(Server $server, ModrinthProjectType $modrinthProjectType, int $page = 1, ?string $search = null): array { $modrinthProjectType = $modrinthProjectType->value; @@ -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"; @@ -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 $versions */ @@ -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); @@ -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 = [];