diff --git a/docs/get-started/usage/game-client/install-manage-mods.md b/docs/get-started/usage/game-client/install-manage-mods.md index f627093c..966c32c1 100644 --- a/docs/get-started/usage/game-client/install-manage-mods.md +++ b/docs/get-started/usage/game-client/install-manage-mods.md @@ -19,6 +19,18 @@ Installing mods is sooo easy. Just go to the **Mods** page, click on the mod you This will install the **Mod** on the **Installation** you've selected on the left menu. +### Suggested Mods + +The **Mods** page can offer a small row of compatible Mods above the normal catalog. The first time +you use it, RiftLauncher asks separately for permission to check the ModDB catalog; it does not +turn on just because you answered another ModDB question. Each suggestion explains why it was +shown, such as being enabled in another compatible Installation, matching categories, trending, +popular, or recently updated. + +Use **Refresh suggestions** to check again, or **Dismiss suggestion** to hide a listing. **Add all +suggestions** opens the same confirmation table used by the regular multi-select install flow; +nothing is downloaded until you confirm the table. + Check out this little guide explaining how to do it: {% embed url="https://www.youtube.com/watch?v=aKqQLtS2WF0" %} diff --git a/src/config/configManager.ts b/src/config/configManager.ts index b238705e..ed6ba7d8 100644 --- a/src/config/configManager.ts +++ b/src/config/configManager.ts @@ -13,6 +13,7 @@ import { normalizeModDbVisibility } from "@domain/moddbVisibility" import { normalizeReceiveBetaUpdates } from "@domain/appUpdate/betaUpdates" import { DEFAULT_COMPRESSION_LEVEL, DEFAULT_CONFIG_BASE } from "@domain/config/defaults" import { normalizeServerBookmarks } from "@domain/servers/bookmarks" +import { MAX_DISMISSED_MOD_SUGGESTIONS } from "@domain/mods/suggestions" const defaultConfig: ConfigType = { ...DEFAULT_CONFIG_BASE, @@ -425,6 +426,15 @@ function normalizeAccounts(value: unknown, atStartup: boolean): AccountPublicTyp .slice(0, MAX_STORED_ACCOUNTS) } +function normalizeModSuggestionsConsent(value: unknown): boolean | null { + return value === true || value === false ? value : null +} + +function normalizeDismissedModSuggestions(value: unknown): number[] { + const ids = Array.isArray(value) ? value.filter((listingId): listingId is number => typeof listingId === "number" && Number.isSafeInteger(listingId) && listingId > 0) : [] + return [...new Set(ids)].slice(0, MAX_DISMISSED_MOD_SUGGESTIONS) +} + /** * `atStartup` is set on the one read that opens a stored document this process has not written: * `getConfig`'s file read. Everything else (every `saveConfig`, every re-normalization of the @@ -491,6 +501,8 @@ export function normalizeConfig(config: unknown, { atStartup = false }: { atStar // `moddbVisibilityAnswer` is the #219 field this replaced, read under its old name so an // install that answered back then migrates rather than being asked as though it never had. moddbVisibility: normalizeModDbVisibility(rawConfig.moddbVisibility ?? (rawConfig as Record)["moddbVisibilityAnswer"], app.getVersion()), + modSuggestionsConsent: normalizeModSuggestionsConsent(rawConfig.modSuggestionsConsent), + dismissedModSuggestions: normalizeDismissedModSuggestions(rawConfig.dismissedModSuggestions), // Null for anything that is not an explicit yes or no, which is what every config written // before the toggle existed says, and leaves the running version deciding as it always did. receiveBetaUpdates: normalizeReceiveBetaUpdates(rawConfig.receiveBetaUpdates), diff --git a/src/domain/config/defaults.ts b/src/domain/config/defaults.ts index d68fc444..ae1e76e0 100644 --- a/src/domain/config/defaults.ts +++ b/src/domain/config/defaults.ts @@ -31,6 +31,8 @@ export const DEFAULT_CONFIG_BASE: Omit typeof listingId === "number" && Number.isSafeInteger(listingId) && listingId > 0) + : [] + } + } +} + /** Every migration the launcher knows, lowest schema first. */ -export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [floatMarkerToIntegerSchema, stampLinkedOnExternalVersions, singleAccountToAccountList, addGameVersionIdentity] +export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [ + floatMarkerToIntegerSchema, + stampLinkedOnExternalVersions, + singleAccountToAccountList, + addGameVersionIdentity, + addModSuggestionsPreferences +] function byFromSchema(migrations: readonly ConfigMigration[]): Map { return new Map(migrations.map((migration) => [migration.fromSchema, migration])) diff --git a/src/domain/mods/suggestions.ts b/src/domain/mods/suggestions.ts new file mode 100644 index 00000000..5622f073 --- /dev/null +++ b/src/domain/mods/suggestions.ts @@ -0,0 +1,215 @@ +import { evaluateModCompatibility, newestCompatibleRelease, type ModCompatibilityVerdict } from "./compatibility" +import { listingDeclaresModid, installedCopiesOf } from "./installedFilters" +import { readModSide } from "./modinfo" + +/** The number of cards shown in the suggestions row. */ +export const MAX_SUGGESTIONS = 6 + +/** The maximum number of detail pages one refresh may ask the ModDB for. */ +export const MAX_SUGGESTION_DETAIL_LOOKUPS = 20 + +/** Dismissed listing ids are history, not an unbounded event log. */ +export const MAX_DISMISSED_MOD_SUGGESTIONS = 1_000 + +/** Named weights keep the heuristic small enough to audit and change deliberately. */ +export const SUGGESTION_SCORE_WEIGHTS = { + categoryOverlap: 32, + trending: 24, + // Popularity is useful context, but a catalog counter must not drown out a direct category match. + popularity: 8, + recency: 16 +} as const + +const DOWNLOAD_LOG_CEILING = 1_000_000 +const FOLLOW_LOG_CEILING = 100_000 +const TRENDING_LOG_CEILING = 1_000 +const RECENCY_HALF_LIFE_DAYS = 30 +const DAY_MS = 24 * 60 * 60 * 1_000 + +/** The local data needed to rank one Installation without reading from disk. */ +export interface SuggestionInstallation { + readonly id: string + readonly version: string + readonly mods: readonly InstalledModType[] +} + +/** The signal values are exposed so the explanation can be checked against the score. */ +export interface SuggestionSignals { + readonly categoryOverlap: number + readonly trending: number + readonly popularity: number + readonly recency: number + readonly otherInstallation: boolean +} + +export type SuggestionReason = + | { readonly kind: "other-installation" } + | { readonly kind: "matching-tags"; readonly tags: readonly string[] } + | { readonly kind: "trending" } + | { readonly kind: "popular" } + | { readonly kind: "recent" } + | { readonly kind: "catalog" } + +/** A catalog listing with its local, explainable ranking result. */ +export interface RankedSuggestion { + readonly mod: DownloadableModOnListType + readonly score: number + readonly signals: SuggestionSignals + readonly reason: SuggestionReason +} + +/** A ranked listing whose detail proved compatible with the target game version. */ +export interface ResolvedSuggestion extends RankedSuggestion { + readonly detail: DownloadableModType + readonly compatibility: ModCompatibilityVerdict +} + +export interface RankSuggestionsInput { + readonly catalog: readonly DownloadableModOnListType[] + readonly installation: SuggestionInstallation + readonly otherInstallations: readonly SuggestionInstallation[] + readonly targetGameVersion: string + readonly dismissedListingIds: readonly number[] + /** Epoch milliseconds. Defaults to the wall clock for production; tests pass a fixed value. */ + readonly now?: number +} + +function positiveNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0 +} + +/** Maps an untrusted catalog counter onto 0..1 without letting it flatten every other signal. */ +function boundedLog(value: unknown, ceiling: number): number { + const positive = positiveNumber(value) + return Math.min(1, Math.log1p(positive) / Math.log1p(ceiling)) +} + +function recency(lastReleased: unknown, now: number): number { + if (typeof lastReleased !== "string") return 0 + const released = Date.parse(lastReleased) + if (!Number.isFinite(released)) return 0 + + const ageDays = Math.max(0, now - released) / DAY_MS + return 1 / (1 + ageDays / RECENCY_HALF_LIFE_DAYS) +} + +function compatibleGameVersion(version: string, target: string): boolean { + return evaluateModCompatibility([version], target) !== "undeclared" +} + +function categoryTagsForInstallation(catalog: readonly DownloadableModOnListType[], mods: readonly InstalledModType[]): Set { + const tags = new Set() + for (const listing of catalog) { + if (!mods.some((mod) => listingDeclaresModid(listing.modidstrs, mod.modid))) continue + for (const tag of listing.tags) tags.add(tag.toLowerCase()) + } + return tags +} + +function otherInstallationMatch(mod: DownloadableModOnListType, input: RankSuggestionsInput): boolean { + return input.otherInstallations.some( + (other) => compatibleGameVersion(other.version, input.targetGameVersion) && other.mods.some((installed) => installed.enabled && listingDeclaresModid(mod.modidstrs, installed.modid)) + ) +} + +function reasonFor(signals: SuggestionSignals, matchingTags: readonly string[]): SuggestionReason { + if (signals.otherInstallation) return { kind: "other-installation" } + + const weighted: Array<{ value: number; reason: SuggestionReason }> = [ + { value: signals.categoryOverlap * SUGGESTION_SCORE_WEIGHTS.categoryOverlap, reason: { kind: "matching-tags", tags: matchingTags } }, + { value: signals.trending * SUGGESTION_SCORE_WEIGHTS.trending, reason: { kind: "trending" } }, + { value: signals.popularity * SUGGESTION_SCORE_WEIGHTS.popularity, reason: { kind: "popular" } }, + { value: signals.recency * SUGGESTION_SCORE_WEIGHTS.recency, reason: { kind: "recent" } } + ] + const winning = weighted.reduce((best, signal) => (signal.value > best.value ? signal : best), { value: 0, reason: { kind: "catalog" } }) + return winning.reason +} + +/** + * Ranks a complete ModDB catalog locally. + * + * This is intentionally a sum of bounded signals, with one separate tier for a compatible enabled + * copy from another Installation. It never reads a file or makes a request, so every suggestion can + * be explained from the data the caller already owns. + */ +export function rankSuggestions(input: RankSuggestionsInput): RankedSuggestion[] { + const dismissed = new Set(input.dismissedListingIds) + const installedHere = input.installation.mods + const installedTags = categoryTagsForInstallation(input.catalog, installedHere) + const now = input.now ?? Date.now() + + return input.catalog + .filter((mod) => typeof mod.type === "string" && mod.type.toLowerCase() === "mod") + .filter((mod) => { + const side = readModSide(typeof mod.side === "string" ? mod.side : undefined) + return side === "client" || side === "both" + }) + .filter((mod) => !dismissed.has(mod.modid)) + .filter((mod) => installedCopiesOf(mod.modidstrs, installedHere).length === 0) + .map((mod) => { + const matchingTags = mod.tags.filter((tag) => installedTags.has(tag.toLowerCase())) + const categoryOverlap = Math.min(matchingTags.length, 3) / 3 + const trending = boundedLog(mod.trendingpoints, TRENDING_LOG_CEILING) + const popularity = boundedLog(mod.downloads, DOWNLOAD_LOG_CEILING) * 0.6 + boundedLog(mod.follows, FOLLOW_LOG_CEILING) * 0.4 + const recent = recency(mod.lastreleased, now) + const otherInstallation = otherInstallationMatch(mod, input) + const signals: SuggestionSignals = { categoryOverlap, trending, popularity, recency: recent, otherInstallation } + const score = + categoryOverlap * SUGGESTION_SCORE_WEIGHTS.categoryOverlap + + trending * SUGGESTION_SCORE_WEIGHTS.trending + + popularity * SUGGESTION_SCORE_WEIGHTS.popularity + + recent * SUGGESTION_SCORE_WEIGHTS.recency + + return { mod, score, signals, reason: reasonFor(signals, matchingTags) } + }) + .sort((one, other) => { + if (one.signals.otherInstallation !== other.signals.otherInstallation) return one.signals.otherInstallation ? -1 : 1 + if (one.score !== other.score) return other.score - one.score + return one.mod.modid - other.mod.modid + }) +} + +export interface ResolveSuggestionsInput { + readonly candidates: readonly RankedSuggestion[] + readonly targetGameVersion: string + readonly getDetail: (listingId: number) => Promise + readonly signal?: AbortSignal + /** Maximum number of compatible details to retain. Defaults to the visible row size. */ + readonly maxSuggestions?: number +} + +function isCancelled(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true +} + +/** + * Confirms the ranked head one detail at a time, preserving rank order and respecting both budgets. + * A cancellation returns no partial row because a stale refresh must never repaint over a newer one. + */ +export async function resolveSuggestions(input: ResolveSuggestionsInput): Promise { + const accepted: ResolvedSuggestion[] = [] + const candidates = input.candidates.slice(0, MAX_SUGGESTION_DETAIL_LOOKUPS) + const maxSuggestions = input.maxSuggestions ?? MAX_SUGGESTIONS + + for (const candidate of candidates) { + if (isCancelled(input.signal)) return [] + + let detail: DownloadableModType | undefined + try { + detail = await input.getDetail(candidate.mod.modid) + } catch { + detail = undefined + } + + if (isCancelled(input.signal)) return [] + if (!detail) continue + + const release = newestCompatibleRelease(detail.releases, input.targetGameVersion) + if (!release) continue + + accepted.push({ ...candidate, detail, compatibility: evaluateModCompatibility(release.tags, input.targetGameVersion) }) + if (accepted.length >= maxSuggestions) break + } + + return accepted +} diff --git a/src/global.d.ts b/src/global.d.ts index 7bdb1c67..8b7c749a 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -43,6 +43,10 @@ declare global { answeredVersion: string countedVersions: string[] } + /** Whether the player has independently opted into ModDB suggestions for the Mods browser. */ + modSuggestionsConsent: boolean | null + /** Listing ids dismissed from the suggestions row, bounded and retained once recorded. */ + dismissedModSuggestions: number[] /** * Whether update checks offer prerelease builds: `true` for yes, `false` for no, and `null` * while nobody has said, which leaves the running version deciding the way electron-updater diff --git a/src/renderer/src/features/config/contexts/ConfigContext.tsx b/src/renderer/src/features/config/contexts/ConfigContext.tsx index 76d28ac6..7fd67325 100644 --- a/src/renderer/src/features/config/contexts/ConfigContext.tsx +++ b/src/renderer/src/features/config/contexts/ConfigContext.tsx @@ -28,6 +28,10 @@ export interface ConfigSettingsType { accentColor: string /** The stored answer to the ModDB listing question, the version it was given under, and what has been counted. See src/domain/moddbVisibility.ts. */ moddbVisibility: ConfigType["moddbVisibility"] + /** Whether the player has opted into the separate ModDB suggestions row, or null while unanswered. */ + modSuggestionsConsent: boolean | null + /** Listing ids dismissed from the suggestions row. */ + dismissedModSuggestions: number[] /** Whether update checks may offer betas, or null while nobody has said. See src/domain/appUpdate/betaUpdates.ts. */ receiveBetaUpdates: boolean | null /** Whether the launcher measures the game process while it runs. See src/domain/sessions/sampling.ts. */ @@ -175,6 +179,8 @@ const ConfigProvider = ({ children }: { children: React.ReactNode }): JSX.Elemen backgroundRevision: config._backgroundRevision ?? 0, accentColor: config.accentColor, moddbVisibility: config.moddbVisibility, + modSuggestionsConsent: config.modSuggestionsConsent, + dismissedModSuggestions: config.dismissedModSuggestions, receiveBetaUpdates: config.receiveBetaUpdates, measurePlaySessions: config.measurePlaySessions, allowBasicSessionStore: config.allowBasicSessionStore, @@ -191,6 +197,8 @@ const ConfigProvider = ({ children }: { children: React.ReactNode }): JSX.Elemen config._backgroundRevision, config.accentColor, config.moddbVisibility, + config.modSuggestionsConsent, + config.dismissedModSuggestions, config.receiveBetaUpdates, config.measurePlaySessions, config.allowBasicSessionStore, diff --git a/src/renderer/src/features/config/contexts/configReducer.ts b/src/renderer/src/features/config/contexts/configReducer.ts index 92dd71b4..5fbe881c 100644 --- a/src/renderer/src/features/config/contexts/configReducer.ts +++ b/src/renderer/src/features/config/contexts/configReducer.ts @@ -1,5 +1,6 @@ import { DEFAULT_CONFIG_BASE } from "@domain/config/defaults" import { type ModDbVisibilityState } from "@domain/moddbVisibility" +import { MAX_DISMISSED_MOD_SUGGESTIONS } from "@domain/mods/suggestions" export enum CONFIG_ACTIONS { SET_CONFIG = "SET_CONFIG", @@ -14,6 +15,8 @@ export enum CONFIG_ACTIONS { SET_BACKGROUND = "SET_BACKGROUND", SET_ACCENT_COLOR = "SET_ACCENT_COLOR", SET_MODDB_VISIBILITY = "SET_MODDB_VISIBILITY", + SET_MOD_SUGGESTIONS_CONSENT = "SET_MOD_SUGGESTIONS_CONSENT", + ADD_DISMISSED_MOD_SUGGESTION = "ADD_DISMISSED_MOD_SUGGESTION", SET_RECEIVE_BETA_UPDATES = "SET_RECEIVE_BETA_UPDATES", SET_MEASURE_PLAY_SESSIONS = "SET_MEASURE_PLAY_SESSIONS", SET_ALLOW_BASIC_SESSION_STORE = "SET_ALLOW_BASIC_SESSION_STORE", @@ -127,6 +130,18 @@ export interface SetModDbVisibility { payload: ModDbVisibilityState } +/** Records the separate answer for the opt-in ModDB suggestions row. */ +export interface SetModSuggestionsConsent { + type: CONFIG_ACTIONS.SET_MOD_SUGGESTIONS_CONSENT + payload: boolean | null +} + +/** Remembers one dismissed listing while keeping dismissal history bounded. */ +export interface AddDismissedModSuggestion { + type: CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION + payload: { listingId: number } +} + /** * Answers, once and for good, whether update checks may offer beta builds. * @@ -323,6 +338,8 @@ export type ConfigAction = | SetBackground | SetAccentColor | SetModDbVisibility + | SetModSuggestionsConsent + | AddDismissedModSuggestion | SetReceiveBetaUpdates | SetMeasurePlaySessions | SetAllowBasicSessionStore @@ -384,6 +401,12 @@ export const configReducer = (config: ConfigType, action: ConfigAction): ConfigT return { ...config, accentColor: action.payload } case CONFIG_ACTIONS.SET_MODDB_VISIBILITY: return { ...config, moddbVisibility: action.payload } + case CONFIG_ACTIONS.SET_MOD_SUGGESTIONS_CONSENT: + return { ...config, modSuggestionsConsent: action.payload } + case CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION: { + if (config.dismissedModSuggestions.includes(action.payload.listingId) || config.dismissedModSuggestions.length >= MAX_DISMISSED_MOD_SUGGESTIONS) return config + return { ...config, dismissedModSuggestions: [...config.dismissedModSuggestions, action.payload.listingId] } + } case CONFIG_ACTIONS.SET_RECEIVE_BETA_UPDATES: return { ...config, receiveBetaUpdates: action.payload } case CONFIG_ACTIONS.SET_MEASURE_PLAY_SESSIONS: diff --git a/src/renderer/src/features/mods/components/ModListCard.tsx b/src/renderer/src/features/mods/components/ModListCard.tsx index d827f817..c4a4d665 100644 --- a/src/renderer/src/features/mods/components/ModListCard.tsx +++ b/src/renderer/src/features/mods/components/ModListCard.tsx @@ -1,4 +1,4 @@ -import { memo, useLayoutEffect, useRef } from "react" +import { memo, useLayoutEffect, useRef, type ReactNode } from "react" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" import { @@ -66,6 +66,7 @@ function ModListCard({ busy = false, updateTo, onAction, + footer, picked, pickDisabled = false }: Readonly<{ @@ -86,6 +87,8 @@ function ModListCard({ /** A newer release tagged for the Installation's game version, once the ModDB details are in. */ updateTo?: string onAction?: (mod: DownloadableModOnListType, action: ModCardAction) => void | Promise + /** Optional content below the card body, used by the suggestions row for its reason and dismiss action. */ + footer?: ReactNode /** Set only in selection mode: whether this Mod is picked. */ picked?: boolean /** The selection is full and this Mod is not in it, so it cannot be picked. */ @@ -171,6 +174,8 @@ function ModListCard({ + {footer} +
{/* * The favorite hue goes on the icon: a colour on the ghost FormButton loses the cascade diff --git a/src/renderer/src/features/mods/components/ModSuggestions.tsx b/src/renderer/src/features/mods/components/ModSuggestions.tsx new file mode 100644 index 00000000..e66df4d7 --- /dev/null +++ b/src/renderer/src/features/mods/components/ModSuggestions.tsx @@ -0,0 +1,143 @@ +import { FiLoader } from "react-icons/fi" +import { PiArrowClockwise, PiX } from "react-icons/pi" +import { useTranslation } from "react-i18next" + +import type { ResolvedSuggestion } from "@domain/mods/suggestions" +import { FormButton } from "@renderer/components/ui/FormComponents" +import { GridGroup, GridWrapper } from "@renderer/components/ui/Grid" +import ModListCard, { type ModCardAction } from "@renderer/features/mods/components/ModListCard" +import { quickInstallKey } from "@renderer/features/mods/hooks/useInstalledModActions" + +function reasonText(suggestion: ResolvedSuggestion, t: (key: string, options?: Record) => string): string { + switch (suggestion.reason.kind) { + case "other-installation": + return t("features.mods.suggestionsReasonOtherInstallation") + case "matching-tags": + return t("features.mods.suggestionsReasonMatchingTags", { count: suggestion.reason.tags.length }) + case "trending": + return t("features.mods.suggestionsReasonTrending") + case "popular": + return t("features.mods.suggestionsReasonPopular") + case "recent": + return t("features.mods.suggestionsReasonRecent") + case "catalog": + return t("features.mods.suggestionsReasonCatalog") + } +} + +/** The opt-in and compact suggestion row above the ordinary ModDB grid. */ +function ModSuggestions({ + consent, + installation, + suggestions, + loading, + selecting, + pickedIds, + isModFav, + isBusy, + onEnable, + onRefresh, + onDismiss, + onAddAll, + onSelect, + onToggleFav, + onOpenModDb, + onAction +}: Readonly<{ + consent: boolean | null + installation: InstallationType | undefined + suggestions: readonly ResolvedSuggestion[] + loading: boolean + selecting: boolean + pickedIds?: ReadonlySet + isModFav: (mod: DownloadableModOnListType) => boolean + isBusy: (key: string) => boolean + onEnable: () => void + onRefresh: () => void + onDismiss: (listingId: number) => void + onAddAll: (mods: readonly DownloadableModOnListType[]) => void | Promise + onSelect: (mod: DownloadableModOnListType) => void + onToggleFav: (mod: DownloadableModOnListType) => void + onOpenModDb: (mod: DownloadableModOnListType) => void + onAction: (mod: DownloadableModOnListType, action: ModCardAction) => void | Promise +}>): JSX.Element | null { + const { t } = useTranslation() + + if (!installation || consent === false) return null + + if (consent === null) { + return ( +
+

{t("features.mods.suggestionsOptInTitle")}

+

{t("features.mods.suggestionsOptInBody")}

+ + {t("features.mods.suggestionsOptInButton")} + +
+ ) + } + + if (!loading && suggestions.length === 0) return null + + const headingId = "mod-suggestions-heading" + return ( +
+ +
+
+

+ {t("features.mods.suggestionsTitle", { installation: installation.name })} +

+

{t("features.mods.suggestionsFooter")}

+
+
+ + + + onAddAll(suggestions.map(({ mod }) => mod))} disabled={loading || suggestions.length === 0}> + {t("features.mods.suggestionsAddAll")} + +
+
+ + + {loading && } + {suggestions.map((suggestion) => ( + + {reasonText(suggestion, t)} + { + event.stopPropagation() + onDismiss(suggestion.mod.modid) + }} + > + + +
+ } + /> + ))} + + + + ) +} + +export default ModSuggestions diff --git a/src/renderer/src/features/mods/hooks/useModSuggestions.ts b/src/renderer/src/features/mods/hooks/useModSuggestions.ts new file mode 100644 index 00000000..68cf731e --- /dev/null +++ b/src/renderer/src/features/mods/hooks/useModSuggestions.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" + +import { parseModListResponse } from "@domain/mods/moddb" +import { MAX_SUGGESTION_DETAIL_LOOKUPS, MAX_SUGGESTIONS, rankSuggestions, resolveSuggestions, type ResolvedSuggestion, type SuggestionInstallation } from "@domain/mods/suggestions" +import { queryModDb } from "@renderer/features/moddb/adapters/moddb" +import type { QueryModOutcome } from "@renderer/features/mods/hooks/useQueryMod" +import { logMods } from "@renderer/features/moddb/adapters/log" + +export interface ModSuggestionsState { + readonly suggestions: readonly ResolvedSuggestion[] + readonly loading: boolean + readonly refresh: () => void +} + +/** + * Loads the optional suggestions row in two deliberately separate phases: one bare catalog + * snapshot, then at most twenty detail checks in rank order. The effect owns an AbortController so + * a refresh, Installation switch, or unmount cannot paint the result of an older request. + */ +export function useModSuggestions({ + consent, + installation, + installations, + installedMods, + dismissedListingIds, + getInstalledMods, + queryMod +}: Readonly<{ + consent: boolean | null + installation: InstallationType | undefined + installations: readonly InstallationType[] + installedMods: readonly InstalledModType[] | undefined + dismissedListingIds: readonly number[] + getInstalledMods: ({ path }: { path: string }) => Promise<{ mods: InstalledModType[]; errors: ErrorInstalledModType[] }> + queryMod: ({ modid }: { modid: number | string }) => Promise +}>): ModSuggestionsState { + const [suggestions, setSuggestions] = useState([]) + const [loading, setLoading] = useState(false) + const [refreshNumber, setRefreshNumber] = useState(0) + const installationId = installation?.id + const installationPath = installation?.path + const installationVersion = installation?.version + const dismissedListingIdsRef = useRef(dismissedListingIds) + dismissedListingIdsRef.current = dismissedListingIds + + const installationsRef = useRef(installations) + installationsRef.current = installations + const installedModsRef = useRef(installedMods) + installedModsRef.current = installedMods + + // Stable key for other installations: id, path, version. Does not change when _modsCount or unrelated fields mutate. + const otherInstallationsKey = useMemo( + () => + installations + .filter((item) => item.id !== installationId) + .map((item) => `${item.id}:${item.path}:${item.version}`) + .join(";"), + [installations, installationId] + ) + + // Stable key for current installed mods: modid, version, enabled. Does not change on array recreation with identical mod list. + const installedModsKey = useMemo(() => (installedMods ? installedMods.map((mod) => `${mod.modid}:${mod.version}:${mod.enabled}`).join(";") : null), [installedMods]) + + const refresh = useCallback(() => setRefreshNumber((number) => number + 1), []) + + useEffect(() => { + if (consent !== true || !installationId || !installationPath || !installationVersion || installedModsKey === null) { + setSuggestions([]) + setLoading(false) + return + } + + const controller = new AbortController() + setLoading(true) + + void (async (): Promise => { + try { + // This path is intentionally unfiltered. The browse grid's current search/order is not a + // suggestions input, and this is the one request that gives the pure ranker a complete view. + const catalogResponse = await queryModDb("/mods") + if (controller.signal.aborted) return + + const catalog = parseModListResponse(catalogResponse) + if (!catalog.ok) { + logMods("error", `[front] [mods] [features/mods/hooks/useModSuggestions.ts] Catalog snapshot failed: ${catalog.reason}.`) + setSuggestions([]) + return + } + + const otherInstallations: SuggestionInstallation[] = [] + for (const other of installationsRef.current) { + if (other.id === installationId) continue + if (controller.signal.aborted) return + + const scanned = await getInstalledMods({ path: other.path }) + if (controller.signal.aborted) return + otherInstallations.push({ id: other.id, version: other.version, mods: scanned.mods }) + } + + const current: SuggestionInstallation = { id: installationId, version: installationVersion, mods: installedModsRef.current ?? [] } + const ranked = rankSuggestions({ + catalog: catalog.payload as unknown as DownloadableModOnListType[], + installation: current, + otherInstallations, + targetGameVersion: installationVersion, + dismissedListingIds: dismissedListingIdsRef.current, + now: Date.now() + }) + + const resolved = await resolveSuggestions({ + candidates: ranked, + targetGameVersion: installationVersion, + maxSuggestions: MAX_SUGGESTION_DETAIL_LOOKUPS, + signal: controller.signal, + getDetail: async (listingId) => { + if (controller.signal.aborted) return undefined + const outcome = await queryMod({ modid: listingId }) + return outcome.status === "found" ? outcome.mod : undefined + } + }) + + if (!controller.signal.aborted) setSuggestions(resolved) + } catch (error) { + if (!controller.signal.aborted) { + logMods("error", `[front] [mods] [features/mods/hooks/useModSuggestions.ts] Suggestions failed: ${error}.`) + setSuggestions([]) + } + } finally { + if (!controller.signal.aborted) setLoading(false) + } + })() + + return (): void => controller.abort() + }, [consent, installationId, installationPath, installationVersion, otherInstallationsKey, installedModsKey, getInstalledMods, queryMod, refreshNumber]) + + const visibleSuggestions = useMemo(() => { + const dismissed = new Set(dismissedListingIds) + return suggestions.filter(({ mod }) => !dismissed.has(mod.modid)).slice(0, MAX_SUGGESTIONS) + }, [dismissedListingIds, suggestions]) + + return { suggestions: visibleSuggestions, loading, refresh } +} diff --git a/src/renderer/src/features/mods/pages/ListMods.tsx b/src/renderer/src/features/mods/pages/ListMods.tsx index a251c55f..9e4f2285 100644 --- a/src/renderer/src/features/mods/pages/ListMods.tsx +++ b/src/renderer/src/features/mods/pages/ListMods.tsx @@ -12,6 +12,7 @@ import { useGetInstalledMods } from "@renderer/features/mods/hooks/useGetInstall import { installedModLookups } from "@renderer/features/mods/hooks/useGetCompleteInstalledMods" import { useInstalledModActions } from "@renderer/features/mods/hooks/useInstalledModActions" import { useQueryMod } from "@renderer/features/mods/hooks/useQueryMod" +import { useModSuggestions } from "@renderer/features/mods/hooks/useModSuggestions" import { useSyncModsCount } from "@renderer/features/mods/hooks/useSyncModsCount" import { logMods } from "@renderer/features/moddb/adapters/log" import { useExternalLinks } from "@renderer/features/mods/hooks/useExternalLinks" @@ -24,6 +25,7 @@ import ModsGrid from "@renderer/features/mods/components/ModsGrid" import DeleteModDialog from "@renderer/features/mods/components/DeleteModDialog" import ImportModpackPopup from "@renderer/features/mods/components/ImportModpackPopup" import ModSelectionBar from "@renderer/features/mods/components/ModSelectionBar" +import ModSuggestions from "@renderer/features/mods/components/ModSuggestions" import type { ModCardAction } from "@renderer/features/mods/components/ModListCard" import { FormButton } from "@renderer/components/ui/FormComponents" import { DEFAULT_LOADED_MODS, getModsBrowseState, updateModsBrowseState, type ModsBrowseState, type ModsFilters } from "@renderer/features/mods/modsBrowseState" @@ -56,7 +58,7 @@ function ListMods(): JSX.Element { const installations = useInstallations() const favMods = useFavMods() const suspendedModUpdates = useSuspendedModUpdates() - const { lastUsedInstallation } = useSettingsConfig() + const { lastUsedInstallation, modSuggestionsConsent, dismissedModSuggestions } = useSettingsConfig() const configDispatch = useConfigDispatch() const { addNotification } = useNotificationsContext() @@ -83,6 +85,20 @@ function ListMods(): JSX.Element { const [installationInstalledMods, setInstallationInstalledMods] = useState(undefined) const installationModsLoadedRef = useRef(false) + const { + suggestions, + loading: suggestionsLoading, + refresh: refreshSuggestions + } = useModSuggestions({ + consent: modSuggestionsConsent, + installation, + installations, + installedMods: installationInstalledMods, + dismissedListingIds: dismissedModSuggestions, + getInstalledMods, + queryMod + }) + // The fast scan is this page's refresh: a folder read, with none of the ModDB lookups the // Manage Mods scan makes for every installed Mod. const actions = useInstalledModActions(installation, triggerGetInstalledMods) @@ -373,13 +389,13 @@ function ListMods(): JSX.Element { } // Returns its promise so Install selected stays busy, and refuses another press, while the folder is read. - async function installPicks(): Promise { + async function installPicks(selectedPicks: readonly ModPick[] = picks): Promise { if (!installation) return const { mods } = await getInstalledMods({ path: installation.path }) // The sidebar stays live during the read. A switch drops the run rather than opening it on the // other Installation; the picks stay, and the next press reads the new folder. if (selectedInstallationId.current !== installation.id) return - const { entries, leftOut } = modSelectionEntries(picks, mods) + const { entries, leftOut } = modSelectionEntries(selectedPicks, mods) setPickRun({ installationId: installation.id, request: { name: "", gameVersion: installation.version, mods: entries }, installedMods: mods, leftOut }) } @@ -439,6 +455,18 @@ function ListMods(): JSX.Element { [installNewest, updateMod, toggleEnabled, toggleSuspended, requestDelete, navigate] ) + function enableSuggestions(): void { + configDispatch({ type: CONFIG_ACTIONS.SET_MOD_SUGGESTIONS_CONSENT, payload: true }) + } + + function dismissSuggestion(listingId: number): void { + configDispatch({ type: CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION, payload: { listingId } }) + } + + function addAllSuggestions(mods: readonly DownloadableModOnListType[]): Promise { + return installPicks(addPicks(picks, mods.map(toModPick))) + } + function clearFilters(): void { setFilter("textFilter", "") setFilter("authorFilter", { userid: "", name: "" }) @@ -488,7 +516,7 @@ function ListMods(): JSX.Element { canInstall={installation !== undefined} onPickVisible={() => setPicks(addPicks(picks, modsList.slice(0, visibleMods).map(toModPick)))} onClear={() => setPicks([])} - onInstall={installPicks} + onInstall={() => installPicks()} /> )} @@ -513,6 +541,25 @@ function ListMods(): JSX.Element {

)} + favMods.includes(mod.modid)} + isBusy={actions.isBusy} + onEnable={enableSuggestions} + onRefresh={refreshSuggestions} + onDismiss={dismissSuggestion} + onAddAll={addAllSuggestions} + onSelect={selecting ? onTogglePick : onSelectMod} + onToggleFav={onToggleFavMod} + onOpenModDb={onOpenModDb} + onAction={onModAction} + /> + { }) }) +describe("addModSuggestionsPreferences", () => { + it("migrates a config written before suggestions existed without inventing consent", () => { + const before = { schemaVersion: 5, favMods: [12] } + const after = addModSuggestionsPreferences.migrate(before) as Record + + assert.deepEqual(after, { schemaVersion: 5, favMods: [12], modSuggestionsConsent: null, dismissedModSuggestions: [] }) + assert.deepEqual(before, { schemaVersion: 5, favMods: [12] }) + }) + + it("keeps valid consent and dismissal history while the migration fills nothing missing", () => { + const after = addModSuggestionsPreferences.migrate({ modSuggestionsConsent: true, dismissedModSuggestions: [9, 10] }) + + assert.deepEqual(after, { modSuggestionsConsent: true, dismissedModSuggestions: [9, 10] }) + }) +}) + describe("floatMarkerToIntegerSchema", () => { it("steps from the float era to the first integer schema", () => { assert.equal(floatMarkerToIntegerSchema.fromSchema, FLOAT_ERA_CONFIG_SCHEMA) @@ -153,8 +170,8 @@ describe("migrateConfigDocument on real configs", () => { const repeatedDoc = repeated.doc as { gameVersions: Array> } assert.equal(result.outcome, "migrated") - assert.equal(result.schema, 5) - assert.deepEqual(result.applied.at(-1), { fromSchema: 4, toSchema: 5 }) + assert.equal(result.schema, 6) + assert.deepEqual(result.applied.at(-1), { fromSchema: 5, toSchema: 6 }) assert.equal(doc.gameVersions[0]!.label, "1.22.7") assert.equal(typeof doc.gameVersions[0]!.id, "string") assert.equal(doc.gameVersions[0]!.id, repeatedDoc.gameVersions[0]!.id, "legacy ids are deterministic") @@ -262,7 +279,8 @@ describe("migrateConfigDocument on real configs", () => { { fromSchema: 1, toSchema: 2 }, { fromSchema: 2, toSchema: 3 }, { fromSchema: 3, toSchema: 4 }, - { fromSchema: 4, toSchema: 5 } + { fromSchema: 4, toSchema: 5 }, + { fromSchema: 5, toSchema: 6 } ]) const doc = result.doc as Record @@ -328,7 +346,8 @@ describe("migrateConfigDocument on real configs", () => { [FLOAT_ERA_CONFIG_SCHEMA, FIRST_INTEGER_CONFIG_SCHEMA], [2, 3], [3, 4], - [4, 5] + [4, 5], + [5, 6] ] ) assert.equal(CONFIG_MIGRATIONS[CONFIG_MIGRATIONS.length - 1]?.toSchema, CURRENT_CONFIG_SCHEMA) diff --git a/tests/domain/mods/suggestions.test.ts b/tests/domain/mods/suggestions.test.ts new file mode 100644 index 00000000..96fe3cfb --- /dev/null +++ b/tests/domain/mods/suggestions.test.ts @@ -0,0 +1,389 @@ +import assert from "node:assert/strict" +import { describe, it } from "vitest" + +import { MAX_SUGGESTION_DETAIL_LOOKUPS, MAX_SUGGESTIONS, rankSuggestions, resolveSuggestions, type SuggestionInstallation } from "../../../src/domain/mods/suggestions" + +const NOW = Date.parse("2026-09-15T12:00:00Z") + +function listing(modid: number, name: string, overrides: Partial = {}): DownloadableModOnListType { + return { + modid, + assetid: modid, + downloads: 0, + follows: 0, + trendingpoints: 0, + comments: 0, + name, + summary: "", + modidstrs: [name.toLowerCase().replaceAll(" ", "-")], + author: "Author", + urlalias: null, + side: "client", + type: "mod", + logo: "", + tags: [], + lastreleased: "2020-01-01", + ...overrides + } +} + +function copy(modid: string, enabled = true): InstalledModType { + return { name: modid, modid, version: "1.0.0", path: `/Mods/${modid}.zip`, enabled } +} + +function installation(id: string, version = "1.22.7", mods: InstalledModType[] = []): SuggestionInstallation { + return { id, version, mods } +} + +function compatibleDetail(mod: DownloadableModOnListType): DownloadableModType { + return { + modid: mod.modid, + assetid: mod.assetid, + name: mod.name, + urlalias: null, + homepageurl: null, + sourcecodeurl: null, + trendingpoints: mod.trendingpoints, + comments: mod.comments, + createdat: "2026-01-01", + tags: mod.tags, + releases: [ + { + releaseid: mod.modid, + mainfile: `https://example.test/${mod.modid}.zip`, + filename: `${mod.modid}.zip`, + fileid: mod.modid, + downloads: 0, + tags: ["1.22.7"], + modidstr: mod.modidstrs[0] ?? "", + modversion: "1.0.0", + created: "2026-01-01", + changelog: "" + } + ] + } +} + +describe("rankSuggestions", () => { + it("requires mod listings on the client or both side and excludes local, dismissed, and duplicate matches", () => { + const candidates = rankSuggestions({ + catalog: [ + listing(1, "Local", { modidstrs: ["LOCAL"] }), + listing(2, "Dismissed"), + listing(3, "Several", { modidstrs: ["several"] }), + listing(4, "Server", { side: "server" }), + listing(5, "Pack", { type: "modpack" }), + listing(6, "Empty identifier", { modidstrs: [""] }), + listing(7, "Client"), + listing(8, "Both", { side: "both" }) + ], + installation: installation("current", "1.22.7", [copy("local"), copy("several"), copy("several", false)]), + otherInstallations: [], + dismissedListingIds: [2], + targetGameVersion: "1.22.7", + now: NOW + }) + + assert.deepEqual( + candidates.map(({ mod }) => mod.modid), + [6, 7, 8] + ) + }) + + it("puts an enabled compatible copy from another installation in its own top tier", () => { + const reused = listing(1, "Already elsewhere") + const disabled = listing(2, "Disabled elsewhere") + const old = listing(3, "Old game elsewhere") + const result = rankSuggestions({ + catalog: [reused, disabled, old], + installation: installation("current"), + otherInstallations: [ + installation("same-game", "1.22.6", [copy("already-elsewhere")]), + installation("disabled", "1.22.7", [copy("disabled-elsewhere", false)]), + installation("old-game", "1.21.9", [copy("old-game-elsewhere")]) + ], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + + assert.equal(result[0]?.mod.modid, reused.modid) + assert.equal(result[0]?.reason.kind, "other-installation") + assert.notEqual(result.find((candidate) => candidate.mod.modid === disabled.modid)?.reason.kind, "other-installation") + assert.notEqual(result.find((candidate) => candidate.mod.modid === old.modid)?.reason.kind, "other-installation") + }) + + it("scores each discovery signal in isolation and explains the winning signal", () => { + const installedTag = listing(99, "Installed", { modidstrs: ["installed"], tags: ["Tweak"] }) + const tagMatch = listing(1, "Tag match", { tags: ["Tweak"] }) + const trending = listing(2, "Trending", { trendingpoints: 100 }) + const popular = listing(3, "Popular", { downloads: 100, follows: 10 }) + const recent = listing(4, "Recent", { lastreleased: "2026-09-14T12:00:00Z" }) + + const result = rankSuggestions({ + catalog: [installedTag, tagMatch, trending, popular, recent], + installation: installation("current", "1.22.7", [copy("installed")]), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + + const byId = new Map(result.map((candidate) => [candidate.mod.modid, candidate])) + assert.equal(byId.get(tagMatch.modid)?.reason.kind, "matching-tags") + assert.equal(byId.get(trending.modid)?.reason.kind, "trending") + assert.equal(byId.get(popular.modid)?.reason.kind, "popular") + assert.equal(byId.get(recent.modid)?.reason.kind, "recent") + + for (const candidate of result) { + const fired = + candidate.reason.kind === "matching-tags" + ? candidate.signals.categoryOverlap > 0 + : candidate.reason.kind === "trending" + ? candidate.signals.trending > 0 + : candidate.reason.kind === "popular" + ? candidate.signals.popularity > 0 + : candidate.reason.kind === "recent" + ? candidate.signals.recency > 0 + : candidate.reason.kind === "other-installation" + assert.equal(fired, true, `${candidate.mod.name} explanation must name a fired signal`) + } + }) + + it("uses bounded logarithmic popularity so one huge listing does not flatten the list", () => { + const huge = listing(1, "Huge", { downloads: 10_000_000_000, follows: 10_000_000_000 }) + const tagMatch = listing(2, "Tag match", { tags: ["Tweak"] }) + const installedTag = listing(99, "Installed", { modidstrs: ["installed"], tags: ["Tweak"] }) + const result = rankSuggestions({ + catalog: [huge, tagMatch, installedTag], + installation: installation("current", "1.22.7", [copy("installed")]), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + + assert.equal(result[0]?.mod.modid, tagMatch.modid) + assert.ok((result.find((candidate) => candidate.mod.modid === huge.modid)?.signals.popularity ?? 1) <= 1) + }) + + it("uses the injected clock and a deterministic listing-id tie break", () => { + const old = listing(1, "Old", { lastreleased: "2026-08-01" }) + const fresh = listing(2, "Fresh", { lastreleased: "2026-09-14" }) + const tiedHighId = listing(20, "High id") + const tiedLowId = listing(10, "Low id") + + const byTime = rankSuggestions({ + catalog: [old, fresh], + installation: installation("current"), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + assert.equal(byTime[0]?.mod.modid, fresh.modid) + + const tied = rankSuggestions({ + catalog: [tiedHighId, tiedLowId], + installation: installation("current"), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + assert.deepEqual( + tied.map(({ mod }) => mod.modid), + [10, 20] + ) + }) + + it("selects the explanation from the largest weighted contribution, not the raw signal", () => { + // categoryOverlap with 1 tag is 1/3 (raw ~0.333), weighted at 32 = 10.67. + // trending with trendingpoints 10 gives log10(11)/log10(1000) ~0.347 (raw ~0.347 > 0.333), + // but weighted at 24 = 8.33 (< 10.67). + // The weighted calculation must choose matching-tags, not trending. + const installedListing = listing(99, "Installed Mod", { modidstrs: ["installed-mod"], tags: ["qol"] }) + const candidate = listing(1, "Candidate", { tags: ["qol"], trendingpoints: 10 }) + const [ranked] = rankSuggestions({ + catalog: [installedListing, candidate], + installation: installation("current", "1.22.7", [copy("installed-mod")]), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + assert.ok(ranked) + assert.deepEqual(ranked.reason, { kind: "matching-tags", tags: ["qol"] }) + }) +}) + +describe("resolveSuggestions", () => { + it("stops after the detail budget when every ranked candidate is incompatible", async () => { + const catalog = Array.from({ length: MAX_SUGGESTION_DETAIL_LOOKUPS + 3 }, (_, index) => listing(index + 1, `Mod ${index + 1}`)) + const ranked = rankSuggestions({ + catalog, + installation: installation("current"), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + const requested: number[] = [] + const result = await resolveSuggestions({ + candidates: ranked, + targetGameVersion: "1.22.7", + getDetail: async (listingId) => { + requested.push(listingId) + return { ...compatibleDetail(catalog[listingId - 1]!), releases: [] } + } + }) + + assert.deepEqual(result, []) + assert.equal(requested.length, MAX_SUGGESTION_DETAIL_LOOKUPS) + }) + + it("keeps accepted declared results in rank order and caps the row at six", async () => { + const catalog = Array.from({ length: MAX_SUGGESTIONS + 2 }, (_, index) => listing(index + 1, `Mod ${index + 1}`)) + const ranked = rankSuggestions({ + catalog, + installation: installation("current"), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + const requested: number[] = [] + const result = await resolveSuggestions({ + candidates: ranked, + targetGameVersion: "1.22.7", + getDetail: async (listingId) => { + requested.push(listingId) + return compatibleDetail(catalog[listingId - 1]!) + } + }) + + assert.deepEqual( + result.map(({ mod }) => mod.modid), + catalog.slice(0, MAX_SUGGESTIONS).map(({ modid }) => modid) + ) + assert.equal(result.length, MAX_SUGGESTIONS) + assert.deepEqual( + requested, + catalog.slice(0, MAX_SUGGESTIONS).map(({ modid }) => modid) + ) + assert.ok(result.every(({ compatibility }) => compatibility === "declared" || compatibility === "same-minor")) + }) + + it("can resolve accepted candidates past the six-card display cap for local backfill", async () => { + const catalog = Array.from({ length: MAX_SUGGESTIONS + 1 }, (_, index) => listing(index + 1, `Mod ${index + 1}`)) + const ranked = rankSuggestions({ + catalog, + installation: installation("current"), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + + const result = await resolveSuggestions({ + candidates: ranked, + targetGameVersion: "1.22.7", + maxSuggestions: MAX_SUGGESTIONS + 1, + getDetail: async (listingId) => compatibleDetail(catalog[listingId - 1]!) + }) + + assert.equal(result.length, MAX_SUGGESTIONS + 1) + assert.deepEqual( + result.map(({ mod }) => mod.modid), + catalog.map(({ modid }) => modid) + ) + }) + + it("caps resolution at MAX_SUGGESTION_DETAIL_LOOKUPS even with more compatible candidates available", async () => { + const catalog = Array.from({ length: MAX_SUGGESTION_DETAIL_LOOKUPS + 10 }, (_, index) => listing(index + 1, `Mod ${index + 1}`)) + const ranked = rankSuggestions({ + catalog, + installation: installation("current"), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + const requested: number[] = [] + const result = await resolveSuggestions({ + candidates: ranked, + targetGameVersion: "1.22.7", + maxSuggestions: MAX_SUGGESTION_DETAIL_LOOKUPS, + getDetail: async (listingId) => { + requested.push(listingId) + return compatibleDetail(catalog[listingId - 1]!) + } + }) + + assert.equal(result.length, MAX_SUGGESTION_DETAIL_LOOKUPS) + assert.equal(requested.length, MAX_SUGGESTION_DETAIL_LOOKUPS) + }) + + it("does not resolve a detail whose releases are undeclared for the target version", async () => { + const candidate = listing(1, "Undeclared") + const detail = compatibleDetail(candidate) + const ranked = rankSuggestions({ + catalog: [candidate], + installation: installation("current"), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + + const result = await resolveSuggestions({ + candidates: ranked, + targetGameVersion: "1.22.7", + getDetail: async () => ({ ...detail, releases: [{ ...detail.releases[0]!, tags: ["1.21.9"] }] }) + }) + + assert.deepEqual(result, []) + }) + + it("does not publish work cancelled before a lookup or after a lookup completes", async () => { + const candidate = rankSuggestions({ + catalog: [listing(1, "Mod")], + installation: installation("current"), + otherInstallations: [], + dismissedListingIds: [], + targetGameVersion: "1.22.7", + now: NOW + }) + const before = new AbortController() + before.abort() + let beforeCalls = 0 + assert.deepEqual( + await resolveSuggestions({ + candidates: candidate, + targetGameVersion: "1.22.7", + signal: before.signal, + getDetail: async () => { + beforeCalls++ + return undefined + } + }), + [] + ) + assert.equal(beforeCalls, 0) + + const after = new AbortController() + let afterCalls = 0 + const result = await resolveSuggestions({ + candidates: candidate, + targetGameVersion: "1.22.7", + signal: after.signal, + getDetail: async () => { + afterCalls++ + after.abort() + return compatibleDetail(listing(1, "Mod")) + } + }) + assert.deepEqual(result, []) + assert.equal(afterCalls, 1) + }) +}) diff --git a/tests/ipc/configManager.test.ts b/tests/ipc/configManager.test.ts index fd8517d9..67839500 100644 --- a/tests/ipc/configManager.test.ts +++ b/tests/ipc/configManager.test.ts @@ -91,6 +91,8 @@ function minimalConfig(overrides: Partial = {}): ConfigType { background: DEFAULT_BACKGROUND_ID, accentColor: DEFAULT_ACCENT_ID, moddbVisibility: defaultModDbVisibility(), + modSuggestionsConsent: null, + dismissedModSuggestions: [], receiveBetaUpdates: DEFAULT_RECEIVE_BETA_UPDATES, measurePlaySessions: DEFAULT_MEASURE_PLAY_SESSIONS, allowBasicSessionStore: DEFAULT_ALLOW_BASIC_SESSION_STORE, @@ -182,6 +184,23 @@ describe("normalizeConfig: the document itself", () => { const { normalizeConfig } = await freshConfigManager() assert.deepEqual(normalizeConfig({}).suspendedModUpdates, []) }) + + it("keeps Mod suggestions consent explicit and never regresses it to a missing default", async () => { + const { normalizeConfig } = await freshConfigManager() + + assert.equal(normalizeConfig({}).modSuggestionsConsent, null) + assert.equal(normalizeConfig({ modSuggestionsConsent: true }).modSuggestionsConsent, true) + assert.equal(normalizeConfig({ modSuggestionsConsent: false }).modSuggestionsConsent, false) + for (const value of ["true", "yes", 1, {}, []]) assert.equal(normalizeConfig({ modSuggestionsConsent: value }).modSuggestionsConsent, null, String(value)) + }) + + it("keeps valid dismissed Mod suggestions and never regresses them to a missing default", async () => { + const { normalizeConfig } = await freshConfigManager() + + assert.deepEqual(normalizeConfig({}).dismissedModSuggestions, []) + assert.deepEqual(normalizeConfig({ dismissedModSuggestions: [4, 4, 2.5, "3", 0, -1, 7] }).dismissedModSuggestions, [4, 7]) + assert.deepEqual(normalizeConfig({ dismissedModSuggestions: "not an array" }).dismissedModSuggestions, []) + }) }) describe("normalizeConfig: installations", () => { diff --git a/tests/renderer-dom/helpers/windowApi.ts b/tests/renderer-dom/helpers/windowApi.ts index 25b8baca..d96034ab 100644 --- a/tests/renderer-dom/helpers/windowApi.ts +++ b/tests/renderer-dom/helpers/windowApi.ts @@ -53,6 +53,8 @@ export function createMockConfig(overrides: MockConfigOverrides = {}): ConfigTyp background: "default", accentColor: "amber", moddbVisibility: { policy: "ask", answeredVersion: "", countedVersions: [] }, + modSuggestionsConsent: null, + dismissedModSuggestions: [], receiveBetaUpdates: null, measurePlaySessions: true, allowBasicSessionStore: false, diff --git a/tests/renderer-dom/modsSuggestions.test.tsx b/tests/renderer-dom/modsSuggestions.test.tsx new file mode 100644 index 00000000..04b90f10 --- /dev/null +++ b/tests/renderer-dom/modsSuggestions.test.tsx @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { Route, Routes } from "react-router-dom" + +import ListMods from "@renderer/features/mods/pages/ListMods" +import { TaskProvider } from "@renderer/contexts/TaskManagerContext" +import { installMockWindowApi, createMockConfig, type MockedBridgeAPI } from "./helpers/windowApi" +import { renderWithProviders } from "./helpers/render" +import { resetModsBrowseState } from "@renderer/features/mods/modsBrowseState" +import { clearQueryCache } from "@renderer/features/mods/hooks/useQueryMods" + +const INSTALLATION: InstallationType = { + id: "install-a", + name: "Install A", + icon: "", + path: "/games/a", + version: "1.22.7", + gameVersionId: null, + startParams: "", + backupsLimit: 3, + backupsAuto: false, + compressionLevel: 6, + backups: [], + lastTimePlayed: -1, + totalTimePlayed: 0, + mesaGlThread: false, + envVars: "" +} + +const CANDIDATE: DownloadableModOnListType = { + modid: 123, + assetid: 123, + downloads: 100, + follows: 5, + trendingpoints: 10, + comments: 1, + name: "Suggestion Candidate", + summary: "A useful Mod.", + modidstrs: ["suggestioncandidate"], + author: "Author", + urlalias: null, + side: "client", + type: "mod", + logo: "", + tags: ["Tweak"], + lastreleased: "2026-09-14" +} + +const BACKFILL_CANDIDATE: DownloadableModOnListType = { + ...CANDIDATE, + modid: 124, + assetid: 124, + name: "Backfill Candidate", + modidstrs: ["backfillcandidate"] +} + +const DETAIL: DownloadableModType = { + modid: 123, + assetid: 123, + name: "Suggestion Candidate", + urlalias: null, + homepageurl: null, + sourcecodeurl: null, + trendingpoints: 10, + comments: 1, + createdat: "2026-01-01", + tags: ["Tweak"], + releases: [ + { + releaseid: 1, + mainfile: "https://mods.example/suggestioncandidate-1.0.0.zip", + filename: "suggestioncandidate-1.0.0.zip", + fileid: 1, + downloads: 1, + tags: ["1.22.7"], + modidstr: "suggestioncandidate", + modversion: "1.0.0", + created: "2026-09-14", + changelog: "" + } + ] +} + +function detailFor(candidate: DownloadableModOnListType): DownloadableModType { + return { + ...DETAIL, + modid: candidate.modid, + assetid: candidate.assetid, + name: candidate.name, + releases: [{ ...DETAIL.releases[0]!, modidstr: candidate.modidstrs[0]!, mainfile: `https://mods.example/${candidate.modidstrs[0]}-1.0.0.zip`, filename: `${candidate.modidstrs[0]}-1.0.0.zip` }] + } +} + +const MANY_CANDIDATES = [ + CANDIDATE, + BACKFILL_CANDIDATE, + ...Array.from({ length: 5 }, (_, index) => ({ ...CANDIDATE, modid: 125 + index, assetid: 125 + index, name: `Suggestion Candidate ${index + 3}`, modidstrs: [`suggestioncandidate${index + 3}`] })) +] + +function mount(consent: boolean | null = null, suggestionCandidates: readonly DownloadableModOnListType[] = [CANDIDATE]): { api: MockedBridgeAPI; queryURL: ReturnType } { + const queryURL = vi.fn(async (url: string): Promise => { + if (url.endsWith("/api/mods") || url.includes("/api/mods?")) return JSON.stringify({ statuscode: "200", mods: suggestionCandidates }) + const candidate = suggestionCandidates.find(({ modid }) => url.endsWith(`/api/mod/${modid}`)) + if (candidate) return JSON.stringify({ statuscode: "200", mod: detailFor(candidate) }) + return JSON.stringify({ statuscode: "200", authors: [], gameversions: [], tags: [] }) + }) + const api = installMockWindowApi({ + configManager: { + getConfig: vi.fn(async () => createMockConfig({ lastUsedInstallation: INSTALLATION.id, installations: [INSTALLATION], modSuggestionsConsent: consent })) + }, + modsManager: { getInstalledMods: vi.fn(async () => ({ mods: [], errors: [] })) }, + netManager: { queryURL } + }) + + renderWithProviders( + + + } /> + + , + { route: "/mods" } + ) + return { api, queryURL } +} + +afterEach(() => { + vi.restoreAllMocks() + window.localStorage.clear() + resetModsBrowseState() + clearQueryCache() +}) + +describe("Mod suggestions", () => { + it("does no suggestion request or card work before the independent opt-in", async () => { + const { queryURL } = mount() + + await screen.findByRole("button", { name: "Suggestion Candidate, Not installed" }, { timeout: 3000 }) + expect(queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods"))).toHaveLength(0) + expect(screen.queryByRole("heading", { name: "Suggested for Install A" })).toBeNull() + expect(screen.getByRole("button", { name: "Turn on Mod suggestions" })).toBeTruthy() + }) + + it("opts in, renders the reason above the grid, refreshes, and persists dismissal", async () => { + const user = userEvent.setup() + const { api, queryURL } = mount() + + await screen.findByRole("button", { name: "Turn on Mod suggestions" }, { timeout: 3000 }) + await user.click(screen.getByRole("button", { name: "Turn on Mod suggestions" })) + + const section = await screen.findByRole("region", { name: "Suggested for Install A" }, { timeout: 3000 }) + await within(section).findByText(/Popular|Recently updated|You run this/i) + await waitFor(() => expect(queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods")).length).toBe(1)) + expect(api.configManager.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ modSuggestionsConsent: true })) + + const beforeRefresh = queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods")).length + await user.click(within(section).getByRole("button", { name: "Refresh suggestions" })) + await waitFor(() => expect(queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods")).length).toBe(beforeRefresh + 1)) + + await user.click(within(section).getByRole("button", { name: "Dismiss suggestion" })) + await waitFor(() => expect(screen.queryByRole("region", { name: "Suggested for Install A" })).toBeNull()) + expect(api.configManager.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ dismissedModSuggestions: [123] })) + }, 15_000) + + it("dismisses one card locally without rerunning the pipeline and backfills from the ranked pool", async () => { + const user = userEvent.setup() + const { queryURL } = mount(null, MANY_CANDIDATES) + + await user.click(await screen.findByRole("button", { name: "Turn on Mod suggestions" }, { timeout: 3000 })) + const section = await screen.findByRole("region", { name: "Suggested for Install A" }, { timeout: 3000 }) + await within(section).findByRole("button", { name: "Suggestion Candidate 6, Not installed" }) + const catalogRequests = (): typeof queryURL.mock.calls => queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods")) + const detailRequests = (): typeof queryURL.mock.calls => queryURL.mock.calls.filter(([url]) => url.includes("/api/mod/")) + await waitFor(() => expect(catalogRequests()).toHaveLength(1)) + const detailsBeforeDismiss = detailRequests().length + + await user.click(within(section).getAllByRole("button", { name: "Dismiss suggestion" })[0]!) + + await waitFor(() => expect(within(section).queryByRole("button", { name: "Suggestion Candidate, Not installed" })).toBeNull()) + expect(within(section).getByRole("button", { name: "Suggestion Candidate 7, Not installed" })).toBeTruthy() + expect(catalogRequests()).toHaveLength(1) + expect(detailRequests()).toHaveLength(detailsBeforeDismiss) + }, 15_000) + + it("Add all opens the existing install confirmation without downloading first", async () => { + const user = userEvent.setup() + const downloadOnPath = vi.fn(async () => "") + const { api } = mount(true) + Object.assign(api.pathsManager, { downloadOnPath }) + + const section = await screen.findByRole("region", { name: "Suggested for Install A" }, { timeout: 3000 }) + await user.click(within(section).getByRole("button", { name: "Add all suggestions" })) + + const dialog = await screen.findByRole("dialog", { name: "Install Selected Mods" }, { timeout: 3000 }) + expect(within(dialog).getByText("Suggestion Candidate")).toBeTruthy() + expect(downloadOnPath).not.toHaveBeenCalled() + }, 15_000) + + it("mounts with consent and 30 candidates runs the pipeline once and caps detail lookups at 20", async () => { + const candidates30 = Array.from({ length: 30 }, (_, index) => ({ + ...CANDIDATE, + modid: 200 + index, + assetid: 200 + index, + name: `Candidate ${index + 1}`, + modidstrs: [`candidate${index + 1}`] + })) + const { queryURL } = mount(true, candidates30) + + const section = await screen.findByRole("region", { name: "Suggested for Install A" }, { timeout: 3000 }) + await within(section).findByRole("button", { name: "Candidate 1, Not installed" }) + + const catalogRequests = (): typeof queryURL.mock.calls => queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods")) + const detailRequests = (): typeof queryURL.mock.calls => queryURL.mock.calls.filter(([url]) => url.includes("/api/mod/")) + + await waitFor(() => expect(catalogRequests()).toHaveLength(1)) + expect(detailRequests()).toHaveLength(20) + }, 15_000) +}) diff --git a/tests/renderer/configReducer.test.ts b/tests/renderer/configReducer.test.ts index c6ed2afc..4b493d08 100644 --- a/tests/renderer/configReducer.test.ts +++ b/tests/renderer/configReducer.test.ts @@ -19,6 +19,7 @@ import { defaultModDbVisibility, MODDB_VISIBILITY_ALWAYS } from "@domain/moddbVi import { DEFAULT_RECEIVE_BETA_UPDATES } from "@domain/appUpdate/betaUpdates" import { DEFAULT_ALLOW_BASIC_SESSION_STORE } from "@domain/account/sessionStorage" import { DEFAULT_MEASURE_PLAY_SESSIONS } from "@domain/sessions/sampling" +import { MAX_DISMISSED_MOD_SUGGESTIONS } from "@domain/mods/suggestions" import { CONFIG_ACTIONS, configReducer, initialState, type ConfigAction } from "../../src/renderer/src/features/config/contexts/configReducer" @@ -39,6 +40,8 @@ function baseConfig(overrides: Partial = {}): ConfigType { background: DEFAULT_BACKGROUND_ID, accentColor: DEFAULT_ACCENT_ID, moddbVisibility: defaultModDbVisibility(), + modSuggestionsConsent: null, + dismissedModSuggestions: [], receiveBetaUpdates: DEFAULT_RECEIVE_BETA_UPDATES, measurePlaySessions: DEFAULT_MEASURE_PLAY_SESSIONS, allowBasicSessionStore: DEFAULT_ALLOW_BASIC_SESSION_STORE, @@ -105,6 +108,33 @@ describe("configReducer: SET_CONFIG", () => { }) }) +describe("configReducer: Mod suggestions", () => { + it("stores independent consent without touching the ModDB visibility answer", () => { + const config = baseConfig() + const result = configReducer(config, { type: CONFIG_ACTIONS.SET_MOD_SUGGESTIONS_CONSENT, payload: true }) + + assert.equal(result.modSuggestionsConsent, true) + assert.deepEqual(result.moddbVisibility, config.moddbVisibility) + }) + + it("deduplicates dismissed listing ids below the cap", () => { + const config = baseConfig({ dismissedModSuggestions: [12, 34] }) + const duplicate = configReducer(config, { type: CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION, payload: { listingId: 12 } }) + assert.deepEqual(duplicate.dismissedModSuggestions, [12, 34]) + assert.equal(duplicate, config) + }) + + it("never exceeds the maximum dismissed listing ids cap and never evicts an older dismissal", () => { + const config = baseConfig({ dismissedModSuggestions: Array.from({ length: MAX_DISMISSED_MOD_SUGGESTIONS }, (_, index) => index + 1) }) + const result = configReducer(config, { type: CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION, payload: { listingId: MAX_DISMISSED_MOD_SUGGESTIONS + 1 } }) + + assert.equal(result.dismissedModSuggestions.length, MAX_DISMISSED_MOD_SUGGESTIONS) + assert.equal(result.dismissedModSuggestions.includes(1), true) + assert.equal(result.dismissedModSuggestions.includes(MAX_DISMISSED_MOD_SUGGESTIONS + 1), false) + assert.equal(result, config) + }) +}) + describe("configReducer: scalar setters", () => { it("SET_LAST_USED_INSTALLATION accepts an id and null alike", () => { const config = baseConfig() diff --git a/tests/text-contrast.test.ts b/tests/text-contrast.test.ts index e263076a..13bc84cd 100644 --- a/tests/text-contrast.test.ts +++ b/tests/text-contrast.test.ts @@ -383,6 +383,16 @@ describe("text over the player's background image", () => { for (const notice of foregrounds("features/mods/components/ServerModsSection.tsx")) assertReadable("server Mods section notice", notice, PAGE, TEXT_FLOOR) }) + it("keeps the Mod suggestions heading and caption readable on the grid panel", () => { + const source = read("features/mods/components/ModSuggestions.tsx") + assert.ok( + source.indexOf("") < source.indexOf('className="relative mb-2 flex flex-wrap items-center justify-between gap-2 px-2"'), + "the suggestions heading should sit inside its grid panel and have relative positioning" + ) + assertReadable("Mod suggestions heading", [ZINC["zinc-200"], 1], [shell, gridPanel], TEXT_FLOOR) + for (const text of foregrounds("features/mods/components/ModSuggestions.tsx")) assertReadable("Mod suggestions text", text, [shell, gridPanel], TEXT_FLOOR) + }) + it("keeps a server group's text readable on the panel it does sit on", () => { for (const text of foregrounds("features/mods/components/ServerModsGroup.tsx")) assertReadable("server Mods group text", text, LIST_PANEL, TEXT_FLOOR) for (const text of foregrounds("features/mods/components/ServerModItem.tsx")) assertReadable("server Mod row text", text, LIST_PANEL, TEXT_FLOOR)