diff --git a/src/celemod-ui/locales/de-DE.json b/src/celemod-ui/locales/de-DE.json index f935645..cf4cd67 100644 --- a/src/celemod-ui/locales/de-DE.json +++ b/src/celemod-ui/locales/de-DE.json @@ -127,6 +127,9 @@ "打开 Mods 文件夹": "Öffnen des Mods-Ordners", "禁用全部": "Alle deaktivieren", "启用全部": "Alle aktivieren", + "启用全部依赖": "Alle Abhängigkeiten aktivieren", + "{alt} 已开启": "{alt} aktiviert", + "{alt} 已开启,可替代本 Mod": "{alt} ist aktiviert, ersetzt dieses Mod", "应用修改": "Änderungen übernehmen", "主树隐藏依赖": "Hauptbaumabgrenzung verstecken", "检查可选依赖": "Optionale Abhängigkeiten überprüfen", diff --git a/src/celemod-ui/locales/en-US.json b/src/celemod-ui/locales/en-US.json index 1383763..1e93d5a 100644 --- a/src/celemod-ui/locales/en-US.json +++ b/src/celemod-ui/locales/en-US.json @@ -115,6 +115,9 @@ "打开 Mods 文件夹": "Open Mods Folder", "禁用全部": "Disable All", "启用全部": "Enable All", + "启用全部依赖": "Enable All Deps", + "{alt} 已开启": "{alt} enabled", + "{alt} 已开启,可替代本 Mod": "{alt} is enabled, substitutes this mod", "检查可选依赖": "Check Optional Deps", "显示完整树": "Full Tree", "显示更新": "Show Updates", diff --git a/src/celemod-ui/locales/fr-FR.json b/src/celemod-ui/locales/fr-FR.json index 12a5e9a..61e3ccd 100644 --- a/src/celemod-ui/locales/fr-FR.json +++ b/src/celemod-ui/locales/fr-FR.json @@ -127,6 +127,9 @@ "打开 Mods 文件夹": "Ouvrez le dossier MODS", "禁用全部": "Désactiver tous les", "启用全部": "Activer tout", + "启用全部依赖": "Activer toutes les dépendances", + "{alt} 已开启": "{alt} activé", + "{alt} 已开启,可替代本 Mod": "{alt} est activé, remplace ce mod", "应用修改": "Modification de l'application", "主树隐藏依赖": "Dépendance cachée de l'arbre principal", "检查可选依赖": "Vérifier les dépendances facultatives", diff --git a/src/celemod-ui/locales/pt-BR.json b/src/celemod-ui/locales/pt-BR.json index 3bb0f10..e19862b 100644 --- a/src/celemod-ui/locales/pt-BR.json +++ b/src/celemod-ui/locales/pt-BR.json @@ -113,6 +113,9 @@ "打开 Mods 文件夹": "Abrir Pasta de Mods", "禁用全部": "Desativar Todos", "启用全部": "Ativar Todos", + "启用全部依赖": "Ativar Todas as Dependências", + "{alt} 已开启": "{alt} ativado", + "{alt} 已开启,可替代本 Mod": "{alt} está ativado, substitui este mod", "检查可选依赖": "Checar Dependências Opcionais", "显示完整树": "Lista Inteira", "显示更新": "Exibir Atualizações", diff --git a/src/celemod-ui/locales/ru-RU.json b/src/celemod-ui/locales/ru-RU.json index 64cb8f9..0063edd 100644 --- a/src/celemod-ui/locales/ru-RU.json +++ b/src/celemod-ui/locales/ru-RU.json @@ -115,6 +115,9 @@ "打开 Mods 文件夹": "Открыть папку модов", "禁用全部": "Отключить все", "启用全部": "Включить все", + "启用全部依赖": "Включить все зависимости", + "{alt} 已开启": "{alt} включён", + "{alt} 已开启,可替代本 Mod": "{alt} включён, заменяет этот мод", "检查可选依赖": "Проверять дополнительные зависимости", "显示完整树": "Всё дерево", "显示更新": "Показать обновления", diff --git a/src/celemod-ui/locales/zh-CN.json b/src/celemod-ui/locales/zh-CN.json index bb56cdf..abe33f0 100644 --- a/src/celemod-ui/locales/zh-CN.json +++ b/src/celemod-ui/locales/zh-CN.json @@ -115,6 +115,9 @@ "打开 Mods 文件夹": "打开 Mods 文件夹", "禁用全部": "禁用全部", "启用全部": "启用全部", + "启用全部依赖": "启用全部依赖", + "{alt} 已开启": "{alt} 已开启", + "{alt} 已开启,可替代本 Mod": "{alt} 已开启,可替代本 Mod", "检查可选依赖": "检查可选依赖", "显示完整树": "显示完整树", "显示更新": "显示更新", diff --git a/src/celemod-ui/src/routes/Manage.tsx b/src/celemod-ui/src/routes/Manage.tsx index c179963..30c8c60 100644 --- a/src/celemod-ui/src/routes/Manage.tsx +++ b/src/celemod-ui/src/routes/Manage.tsx @@ -4,12 +4,23 @@ import './Manage.scss'; import { BackendDep, BackendModInfo, + initAutoDisableNewMods, + initCheckOptionalDep, + initExcludeDependents, + initFullTree, + initShowDetailed, + initShowUpdate, useAlwaysOnMods, useAutoDisableNewMods, + useCheckOptionalDep, useCurrentBlacklistProfile, + useExcludeDependents, + useFullTree, useGamePath, useInstalledMods, useModComments, + useShowDetailed, + useShowUpdate, useStorage, } from '../states'; import { useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks'; @@ -70,6 +81,19 @@ type ModDepInfo = ModInfoProbablyMissing & { optional: boolean; }; +// Mods that can stand in for one another: if any one in the list is enabled, +// a dependency on any *other* member is treated as satisfied. Special-case for +// the multiplayer clients — CelesteNet.Client and its CN fork MiaoNet. +// (MiaoNet's installed name is literally "MiaoNet"; see main.rs +// get_installed_miaonet.) +const CELESTENET_ALT_LIST = ['CelesteNet.Client', 'MiaoNet', 'Miao.CelesteNet.Client']; +// Returns the (raw) names of all enabled alternatives covering `name` (excludes +// `name` itself). Empty if `name` isn't in the list or no alternative is on. +const altCovering = (name: string, modMap: Map): string[] => { + if (!CELESTENET_ALT_LIST.includes(name)) return []; + return CELESTENET_ALT_LIST.filter((alt) => alt !== name && modMap.get(alt)?.enabled); +}; + const modListContext = createContext<{ switchMod: (id: string, enabled: boolean, recursive?: boolean) => void; switchProfile: (name: string) => void; @@ -84,6 +108,7 @@ const modListContext = createContext<{ showDetailed: boolean; alwaysOnMods: string[]; switchAlwaysOn: (name: string, enabled: boolean) => void; + isCoveredByAlt: (name: string) => string[]; autoDisableNewMods: boolean; hasUpdateMods: { name: string; @@ -241,6 +266,7 @@ const ModLocal = ({ }, [name, ctx.hasUpdateMods]); const isAlwaysOn = ctx?.alwaysOnMods.includes(name); + const coveredByAlt = ctx?.isCoveredByAlt?.(name) ?? []; const [editingComment, setEditingComment] = useState(false); const refCommentInput = useRef(null); @@ -370,6 +396,20 @@ const ModLocal = ({ )} + {coveredByAlt.length > 0 && ( + + {_i18n.t('{alt} 已开启', { + alt: coveredByAlt.join(', '), + })} + + )} + setEditingComment(true)} @@ -575,11 +615,19 @@ export const Manage = () => { const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [excludeDependents, setExcludeDependents] = useState(true); - const [checkOptionalDep, setCheckOptionalDep] = useState(false); - const [fullTree, setFullTree] = useState(false); - const [showUpdate, setShowUpdate] = useState(true); - const [showDetailed, setShowDetailed] = useState(false); + // Load persisted preference toggles from storage (each init* hook mounts + // the useEffect in createPersistedState that reads storage.root[key]). + initAutoDisableNewMods(); + initExcludeDependents(); + initCheckOptionalDep(); + initFullTree(); + initShowUpdate(); + initShowDetailed(); + const [excludeDependents, setExcludeDependents] = useExcludeDependents(); + const [checkOptionalDep, setCheckOptionalDep] = useCheckOptionalDep(); + const [fullTree, setFullTree] = useFullTree(); + const [showUpdate, setShowUpdate] = useShowUpdate(); + const [showDetailed, setShowDetailed] = useShowDetailed(); const [fullCheckRunning, setFullCheckRunning] = useState(false); const installedModMap = useMemo(() => { @@ -637,7 +685,9 @@ export const Manage = () => { } if (!modMap.has(dep.name)) { - mergeSM({ status: 'missing', message: '' }, dep.name); + if (altCovering(dep.name, modMap).length === 0) { + mergeSM({ status: 'missing', message: '' }, dep.name); + } continue; } @@ -652,7 +702,7 @@ export const Manage = () => { ); } - if (!installedDep.enabled) { + if (!installedDep.enabled && altCovering(dep.name, modMap).length === 0) { mergeSM( { status: 'not-enabled', @@ -821,7 +871,14 @@ export const Manage = () => { modTree.set(mod.name, mod); } + // Track the current DFS stack so dependency cycles terminate. Optional + // dependencies commonly form cycles (e.g. two mods that mutually enhance + // each other); without this guard, enabling "检查可选依赖" makes dfsRemove + // recurse forever and overflow the stack. Same idiom as renderPath + // cycle detection above and the `visited` sets used by other traversals. + const dfsPath = new Set(); const dfsRemove = (mod: ModInfoProbablyMissing, isRoot = false) => { + if (dfsPath.has(mod.name)) return; if (filter && checkFilter(filter, mod)) return; if (!isRoot) { modTree.delete(mod.name); @@ -830,13 +887,15 @@ export const Manage = () => { return; } + dfsPath.add(mod.name); for (const dep of mod.dependencies) { - if ((dep as any)._missing || dep.optional) { + if ((dep as any)._missing || (dep.optional && !checkOptionalDep)) { continue; } dfsRemove(dep); } + dfsPath.delete(mod.name); }; if (excludeDependents) @@ -855,7 +914,7 @@ export const Manage = () => { return [...modTree.values()].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) ); - }, [installedModMap, excludeDependents, filter]); + }, [installedModMap, excludeDependents, filter, checkOptionalDep]); useEffect(() => { // @ts-ignore @@ -870,6 +929,7 @@ export const Manage = () => { if (enabled) setAlwaysOnMods([...alwaysOnMods, name]); else setAlwaysOnMods(alwaysOnMods.filter((v) => v !== name)); }, + isCoveredByAlt: (name: string) => altCovering(name, installedModMap), alwaysOnMods, autoDisableNewMods, modComments, setModComment(name: string, comment: string) { @@ -944,6 +1004,7 @@ export const Manage = () => { const switchList: string[] = []; const excludeFromAutoEnableList = [ 'CelesteNet.Client', + 'MiaoNet', 'Miao.CelesteNet.Client', ]; @@ -1137,6 +1198,7 @@ export const Manage = () => { [ currentProfile, installedMods, + installedModMap, gamePath, modPath, fullTree, @@ -1336,6 +1398,19 @@ export const Manage = () => { > {_i18n.t('启用全部')} +    +