diff --git a/CHANGELOG.md b/CHANGELOG.md index 30e3a8e..cfe25bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to ModShift are documented here. The format follows - An update that keeps the same version number (a file rebuilt for the target Minecraft version, e.g. `3.3.6 → 3.3.6`) is now explained in the report instead of looking like an error. +- Resource packs and shaders listed in a pack (e.g. Faithful 32x in a CurseForge export) were + searched as mods for the target loader and reported as not found. They are now looked up + under their own Modrinth loaders, installed in `resourcepacks/` or `shaderpacks/`, linked to + their right Modrinth page, and never get mod alternatives. ## [0.1.0] - 2026-10-20 diff --git a/src/core/contentTypes.ts b/src/core/contentTypes.ts new file mode 100644 index 0000000..2f8a630 --- /dev/null +++ b/src/core/contentTypes.ts @@ -0,0 +1,41 @@ +// Types de contenu d'un pack autres que les mods : packs de ressources et shaders. +// +// Sources (code de Modrinth, validateurs de labrinth) : apps/labrinth/src/validate/resourcepack.rs +// accepte le loader « minecraft » ; shader.rs accepte « optifine » et « iris », « canvas » +// (CanvasShaderValidator) et « vanilla ». Dossiers d'installation : « resourcepacks » (packwiz, +// curseforge.go) et « shaderpacks » (dossier lu par Iris et OptiFine). + +import type { MigrationTarget } from '../types.js'; +import { acceptedLoaders } from './loaders.js'; + +export type ContentType = 'mod' | 'resourcepack' | 'shader'; + +export function contentTypeOf(projectType: string | undefined): ContentType { + return projectType === 'resourcepack' || projectType === 'shader' ? projectType : 'mod'; +} + +// Loaders Modrinth sous lesquels chercher une version pour la cible. Un pack de ressources ne +// dépend pas du loader de mods : seule la version de Minecraft compte. +export function loadersFor(type: ContentType, target: MigrationTarget): string[] { + if (type === 'resourcepack') return ['minecraft']; + if (type === 'shader') return ['iris', 'optifine', 'canvas', 'vanilla']; + return acceptedLoaders(target.loader); +} + +export const CONTENT_FOLDERS: Record = { + mod: 'mods', + resourcepack: 'resourcepacks', + shader: 'shaderpacks', +}; + +// Libellé lisible dans les raisons (« No version for Minecraft 26.3 (resource pack). »). +export const CONTENT_LABELS: Record = { + mod: 'mod', + resourcepack: 'resource pack', + shader: 'shader', +}; + +// Segment d'URL des pages de projet Modrinth (modrinth.com//). +export function modrinthPageUrl(project: { slug: string; projectType?: string }): string { + return `https://modrinth.com/${contentTypeOf(project.projectType)}/${project.slug}`; +} diff --git a/src/core/engine.ts b/src/core/engine.ts index b222ab9..ba2e13a 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -6,6 +6,7 @@ import type { ModrinthProject, ModrinthVersion } from '../modrinth/types.js'; import type { Loader, MigrationTarget, ModEntry, ModPack } from '../types.js'; import { DEFAULT_CONCURRENCY, mapConcurrent } from '../concurrency.js'; import { findAlternatives } from './alternatives.js'; +import { CONTENT_LABELS, contentTypeOf, loadersFor } from './contentTypes.js'; import { resolveDependencies, type SelectedVersion } from './dependencies.js'; import { acceptedLoaders, @@ -194,11 +195,14 @@ export async function analyzePack( let done = 0; tick(0, distinctProjects.length, distinctProjects[0]?.title ?? 'done'); await mapConcurrent(distinctProjects, concurrency, async (project) => { + // Un pack de ressources ou un shader se cherche sous ses propres loaders, pas sous le loader + // de mods de la cible. + const loaders = loadersFor(contentTypeOf(project.project_type), target); const versions = await api.getProjectVersions(project.id, { - loaders: acceptedLoaders(target.loader), + loaders, gameVersions: [target.minecraftVersion], }); - bestByProject.set(project.id, pickBestVersion(versions, target)); + bestByProject.set(project.id, pickBestVersion(versions, target, loaders)); done++; if (done < distinctProjects.length) tick(done, distinctProjects.length, project.title); }); @@ -212,6 +216,8 @@ export async function analyzePack( pack.mods.flatMap((entry) => { const id = identified.get(entry); if (ignoredEntries.has(entry) || mapped.has(entry) || !id) return []; + // Alternatives et couches de compatibilité ne concernent que les mods. + if (contentTypeOf(id.project.project_type) !== 'mod') return []; if (id.currentVersion && versionSupportsTarget(id.currentVersion, target)) return []; if (bestByProject.get(id.project.id)) return []; return [[id.project.id, id.project] as const]; @@ -302,7 +308,9 @@ export async function analyzePack( currentVersion: current ? toVersionRef(current) : undefined, }; - if (current && versionSupportsTarget(current, target)) { + const contentType = contentTypeOf(id.project.project_type); + const contentLoaders = loadersFor(contentType, target); + if (current && versionSupportsTarget(current, target, contentLoaders)) { const fallback = fallbackCompatibility(current, target); mods.push({ ...common, @@ -318,7 +326,7 @@ export async function analyzePack( const best = bestByProject.get(project.id); if (best) { const fallback = fallbackCompatibility(best, target); - const change = loaderChange(pack.loader, best, target); + const change = contentType === 'mod' ? loaderChange(pack.loader, best, target) : undefined; mods.push({ ...common, status: 'update-found', @@ -340,7 +348,10 @@ export async function analyzePack( const { alternatives, compatLayer } = found; // Une seule fois par projet, même s'il apparaît dans plusieurs fichiers. warnings.push(...found.warnings.splice(0)); - const noVersion = `${idNote}No version for Minecraft ${target.minecraftVersion} / ${target.loader}.`; + const noVersion = + contentType === 'mod' + ? `${idNote}No version for Minecraft ${target.minecraftVersion} / ${target.loader}.` + : `${idNote}No version for Minecraft ${target.minecraftVersion} (${CONTENT_LABELS[contentType]}).`; const adopted = options.adoptAlternatives ? await adoptAlternative(api, alternatives, target, selected) : undefined; diff --git a/src/core/loaders.ts b/src/core/loaders.ts index bc5da18..ad0b890 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -44,8 +44,13 @@ export function loaderChangeReason(change: LoaderChange): string { return `Same project: its ${to} version replaces the ${from} one (multi-loader mod).`; } -export function versionSupportsTarget(version: ModrinthVersion, target: MigrationTarget): boolean { - const loaders = acceptedLoaders(target.loader); +// `loaders` : loaders acceptés (ceux de la cible par défaut ; « minecraft » pour un pack de +// ressources, voir src/core/contentTypes.ts). +export function versionSupportsTarget( + version: ModrinthVersion, + target: MigrationTarget, + loaders: string[] = acceptedLoaders(target.loader), +): boolean { return ( version.game_versions.includes(target.minecraftVersion) && version.loaders.some((l) => loaders.includes(l)) @@ -59,10 +64,11 @@ const VERSION_TYPE_RANK: Record = { release: 0, beta: 1, al export function pickBestVersion( versions: ModrinthVersion[], target: MigrationTarget, + loaders?: string[], ): ModrinthVersion | undefined { const native = (v: ModrinthVersion) => (v.loaders.includes(target.loader) ? 0 : 1); return versions - .filter((v) => versionSupportsTarget(v, target)) + .filter((v) => versionSupportsTarget(v, target, loaders)) .filter((v) => v.status === undefined || v.status === 'listed' || v.status === 'archived') .sort( (a, b) => diff --git a/src/core/refs.ts b/src/core/refs.ts index ac10cd4..d31e4be 100644 --- a/src/core/refs.ts +++ b/src/core/refs.ts @@ -19,6 +19,10 @@ export function toProjectRef(project: ModrinthProject): ProjectRef { slug: project.slug, title: project.title, categories: project.categories ?? [], + // Seulement pour les packs de ressources, shaders… (page Modrinth et dossier différents). + ...(project.project_type && project.project_type !== 'mod' + ? { projectType: project.project_type } + : {}), }; } diff --git a/src/core/types.ts b/src/core/types.ts index 8d905e1..0927f52 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -25,6 +25,8 @@ export interface ProjectRef { slug: string; title: string; categories: string[]; + // Type de projet Modrinth quand ce n'est pas un mod (« resourcepack », « shader »…). + projectType?: string; } export interface VersionRef { diff --git a/src/curseforge/client.ts b/src/curseforge/client.ts index 9bc8eee..ecaf971 100644 --- a/src/curseforge/client.ts +++ b/src/curseforge/client.ts @@ -34,6 +34,9 @@ export interface CurseForgeMod { name: string; slug: string; summary?: string; + // Classe CurseForge : 6 mod, 12 pack de ressources, 6552 shader, 17 monde (Prism Launcher, + // FlameAPI.cpp ; packwiz, curseforge.go). + classId?: number; links?: { websiteUrl?: string }; } diff --git a/src/curseforge/resolve.ts b/src/curseforge/resolve.ts index 7cf1c73..5a7947c 100644 --- a/src/curseforge/resolve.ts +++ b/src/curseforge/resolve.ts @@ -43,7 +43,7 @@ export async function resolveCurseForgeRefs( } resolved++; ref.resolved = true; - const path = `mods/${file.fileName}`; + const path = `${folderForClass(mod?.classId)}/${file.fileName}`; if (!file.fileName.includes('/') && isSpecSafePath(path)) { entry.fileName = file.fileName; entry.path = path; @@ -55,6 +55,13 @@ export async function resolveCurseForgeRefs( return { resolved, unresolved: refs.length - resolved }; } +// Dossier d'installation d'un fichier selon sa classe CurseForge (voir CurseForgeMod.classId). +function folderForClass(classId: number | undefined): string { + if (classId === 12) return 'resourcepacks'; + if (classId === 6552) return 'shaderpacks'; + return 'mods'; +} + // Avertissement quand des références CurseForge restent non résolues faute de clé. export function missingKeyWarning(pack: ModPack): string | undefined { const count = pack.mods.filter((m) => m.curseforge && !m.curseforge.resolved).length; diff --git a/src/migrate/plan.ts b/src/migrate/plan.ts index fbbdede..bb1ffba 100644 --- a/src/migrate/plan.ts +++ b/src/migrate/plan.ts @@ -1,3 +1,4 @@ +import { CONTENT_FOLDERS, contentTypeOf } from '../core/contentTypes.js'; import { mrpackEnv, type ModSide, neededOn, type TargetSide } from '../core/sides.js'; import type { AnalysisResult, LoaderChange, ModAnalysis, VersionRef } from '../core/types.js'; import { sideLabel } from '../report/labels.js'; @@ -29,6 +30,8 @@ export interface PlannedMod { requiredBy?: string[]; // Mod identifié seulement par son mod id (le fichier d'origine n'est pas connu de Modrinth). unverified?: boolean; + // Type de projet Modrinth quand ce n'est pas un mod (dossier du fichier dans le pack). + projectType?: string; // Version retenue pour un loader de repli (« fallback compatibility, to test »). fallbackLoader?: string; // Même projet, version d'un autre loader (mod multi-loader), ex. { from: 'forge', to: 'fabric' }. @@ -90,10 +93,12 @@ function normalizeEnv(env: ModPack['mods'][number]['env']): MrpackFile['env'] { function fileFromVersion( version: VersionRef, env: MrpackFile['env'], + projectType?: string, ): MrpackFile | { error: string } { const file = version.file; if (!file) return { error: 'the Modrinth version has no file' }; - const path = `mods/${file.fileName}`; + // Packs de ressources et shaders vont dans leur propre dossier, pas dans mods/. + const path = `${CONTENT_FOLDERS[contentTypeOf(projectType)]}/${file.fileName}`; if (!isSpecSafePath(path) || file.fileName.includes('/')) { return { error: `unsafe file name "${file.fileName}"` }; } @@ -156,7 +161,7 @@ export function buildMigrationPlan( // Ajoute un mod s'il n'est pas déjà présent (même projet ou même chemin). const add = (mod: Omit, env: MrpackFile['env']): boolean => { if (projectIds.has(mod.projectId)) return true; - const file = fileFromVersion(mod.version, env); + const file = fileFromVersion(mod.version, env, mod.projectType); if ('error' in file) { warnings.push(`${mod.title}: not included, ${file.error}`); return false; @@ -222,6 +227,7 @@ export function buildMigrationPlan( source: mod.status === 'compatible' ? 'kept' : 'updated', title: mod.project.title, projectId: mod.project.id, + ...(mod.project.projectType ? { projectType: mod.project.projectType } : {}), version: mod.targetVersion, replaces: mod.fileName, ...(mod.identification === 'mod-id' ? { unverified: true } : {}), @@ -266,6 +272,7 @@ export function buildMigrationPlan( source: 'dependency', title: dep.project.title, projectId: dep.project.id, + ...(dep.project.projectType ? { projectType: dep.project.projectType } : {}), version: dep.version, requiredBy: dep.requiredBy, ...(dep.fallback ? { fallbackLoader: dep.fallback.loader } : {}), diff --git a/src/report/markdown.ts b/src/report/markdown.ts index 263779d..2b7a238 100644 --- a/src/report/markdown.ts +++ b/src/report/markdown.ts @@ -1,4 +1,5 @@ import type { AnalysisResult } from '../core/types.js'; +import { modrinthPageUrl } from '../core/contentTypes.js'; import { MOD_STATUSES } from '../core/types.js'; import { CONFLICT_LABELS, @@ -58,7 +59,7 @@ export function renderMarkdown(result: AnalysisResult): string { ['Status', 'Mod', 'File', 'Current', 'Target', 'Notes'], result.mods.map((m) => [ `${STATUS_ICONS[m.status]} ${STATUS_LABELS[m.status]}`, - m.project ? `[${m.project.title}](https://modrinth.com/mod/${m.project.slug})` : m.fileName, + m.project ? `[${m.project.title}](${modrinthPageUrl(m.project)})` : m.fileName, m.fileName, versionLabel(m.currentVersion), versionLabel(m.targetVersion), diff --git a/test/core/resourcePacks.test.ts b/test/core/resourcePacks.test.ts new file mode 100644 index 0000000..447e037 --- /dev/null +++ b/test/core/resourcePacks.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { analyzePack } from '../../src/core/engine.js'; +import type { CurseForgeClient } from '../../src/curseforge/client.js'; +import { resolveCurseForgeRefs } from '../../src/curseforge/resolve.js'; +import { buildMigrationPlan } from '../../src/migrate/plan.js'; +import type { ModrinthVersion } from '../../src/modrinth/types.js'; +import { renderMarkdown } from '../../src/report/markdown.js'; +import type { ModPack } from '../../src/types.js'; +import { createFakeClient, loadCatalog } from '../helpers/fakeModrinth.js'; + +// Cas réel : un export CurseForge listait « Faithful 32x » (pack de ressources) parmi ses mods ; +// ModShift le cherchait sous le loader « fabric » et le déclarait introuvable. +function version( + id: string, + projectId: string, + gameVersion: string, + hash: string, +): ModrinthVersion { + return { + id, + project_id: projectId, + name: id, + version_number: gameVersion, + game_versions: [gameVersion], + // Packs de ressources : loader « minecraft » (labrinth, validate/resourcepack.rs). + loaders: ['minecraft'], + version_type: 'release', + date_published: '2024-09-01T00:00:00Z', + dependencies: [], + status: 'listed', + files: [ + { + hashes: { sha1: hash.repeat(20), sha512: hash.repeat(64) }, + url: `https://cdn.modrinth.com/data/fixture/${id}.zip`, + filename: `${id}.zip`, + primary: true, + size: 1000, + }, + ], + }; +} + +function catalog() { + const base = loadCatalog(); + for (const [id, title] of [ + ['FAITHFL1', 'Faithful Fixture'], + ['OLDPACK1', 'Old Texture Pack'], + ] as const) { + base.projects.push({ + id, + slug: id.toLowerCase(), + title, + description: `${title}, a resource pack.`, + categories: ['16x'], + project_type: 'resourcepack', + loaders: ['minecraft'], + game_versions: ['1.20.1', '1.21.1'], + downloads: 1000, + }); + } + base.versions.push( + version('v1faith', 'FAITHFL1', '1.20.1', 'b6'), + version('v2faith', 'FAITHFL1', '1.21.1', 'b7'), + // Pas de version 1.21.1 : introuvable, sans recherche d'alternatives parmi les mods. + version('v1oldpack', 'OLDPACK1', '1.20.1', 'e8'), + ); + return base; +} + +const entry = (name: string, hash: string) => ({ + fileName: name, + path: `resourcepacks/${name}`, + origin: 'index' as const, + hashes: { sha1: hash.repeat(20) }, + metadata: [], +}); + +const pack = (): ModPack => ({ + source: 'curseforge', + minecraftVersion: '1.20.1', + loader: 'fabric', + mods: [entry('Faithful 32x - 1.20.1.zip', 'b6'), entry('old-pack.zip', 'e8')], + otherFiles: [], + warnings: [], +}); + +describe('resource packs listed among the mods', () => { + it('finds their version under the "minecraft" loader and installs them in resourcepacks/', async () => { + const { client, calls } = createFakeClient(catalog()); + const result = await analyzePack( + pack(), + { minecraftVersion: '1.21.1', loader: 'fabric' }, + client, + ); + const [faithful, old] = result.mods; + expect(faithful).toMatchObject({ + status: 'update-found', + project: { id: 'FAITHFL1', projectType: 'resourcepack' }, + targetVersion: { id: 'v2faith' }, + }); + expect(old).toMatchObject({ + status: 'not-found', + alternatives: [], + reason: 'No version for Minecraft 1.21.1 (resource pack).', + }); + // Aucune recherche d'alternatives (ce serait des mods) pour un pack de ressources. + expect(calls.some((c) => c.url.pathname.endsWith('/search'))).toBe(false); + + const plan = buildMigrationPlan(pack(), result, { loaderVersion: '0.16.5' }); + expect(plan.index.files.map((f) => f.path)).toContain('resourcepacks/v2faith.zip'); + expect(renderMarkdown(result)).toContain( + '[Faithful Fixture](https://modrinth.com/resourcepack/faithfl1)', + ); + }); + + it('puts CurseForge resource packs (class 12) and shaders (class 6552) in their folders', async () => { + const cfPack: ModPack = { + source: 'curseforge', + mods: [1, 2, 3].map((n) => ({ + fileName: `curseforge:${n}:${n}`, + path: `mods/curseforge-${n}`, + origin: 'index' as const, + hashes: {}, + metadata: [], + curseforge: { projectId: n, fileId: n * 10, required: true }, + })), + otherFiles: [], + warnings: [], + }; + const client = { + getFiles: async () => [ + { id: 10, modId: 1, fileName: 'pack.zip', hashes: [] }, + { id: 20, modId: 2, fileName: 'shader.zip', hashes: [] }, + { id: 30, modId: 3, fileName: 'mod.jar', hashes: [] }, + ], + getMods: async () => [ + { id: 1, name: 'Pack', slug: 'pack', classId: 12 }, + { id: 2, name: 'Shader', slug: 'shader', classId: 6552 }, + { id: 3, name: 'Mod', slug: 'mod', classId: 6 }, + ], + } as unknown as CurseForgeClient; + await resolveCurseForgeRefs(cfPack, client); + expect(cfPack.mods.map((m) => m.path)).toEqual([ + 'resourcepacks/pack.zip', + 'shaderpacks/shader.zip', + 'mods/mod.jar', + ]); + }); +});