Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/get-started/usage/game-client/install-manage-mods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" %}
Expand Down
12 changes: 12 additions & 0 deletions src/config/configManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>)["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),
Expand Down
2 changes: 2 additions & 0 deletions src/domain/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const DEFAULT_CONFIG_BASE: Omit<ConfigType, "schemaVersion" | "defaultIns
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,
Expand Down
27 changes: 25 additions & 2 deletions src/domain/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

/** Schema every config the launcher writes today carries. */
export const CURRENT_CONFIG_SCHEMA = 5
export const CURRENT_CONFIG_SCHEMA = 6

/**
* First schema expressed as an integer.
Expand Down Expand Up @@ -339,8 +339,31 @@ export const addGameVersionIdentity: ConfigMigration = {
}
}

/** Adds the independent ModDB suggestions answer and bounded dismissal history. */
export const addModSuggestionsPreferences: ConfigMigration = {
fromSchema: 5,
toSchema: 6,
migrate(doc: unknown): unknown {
if (!isRecord(doc)) return doc

return {
...doc,
modSuggestionsConsent: doc.modSuggestionsConsent === true || doc.modSuggestionsConsent === false ? doc.modSuggestionsConsent : null,
dismissedModSuggestions: Array.isArray(doc.dismissedModSuggestions)
? doc.dismissedModSuggestions.filter((listingId): listingId is number => 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<number, ConfigMigration> {
return new Map(migrations.map((migration) => [migration.fromSchema, migration]))
Expand Down
215 changes: 215 additions & 0 deletions src/domain/mods/suggestions.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const tags = new Set<string>()
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<DownloadableModType | undefined>
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<ResolvedSuggestion[]> {
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
}
4 changes: 4 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/features/config/contexts/ConfigContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
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. */
Expand Down Expand Up @@ -117,7 +121,7 @@
addNotification(t("notifications.body.configSaveRecovered"), "success")
}
})
}, [config])

Check warning on line 124 in src/renderer/src/features/config/contexts/ConfigContext.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'addNotification', 'isConfigLoaded', and 't'. Either include them or remove the dependency array

useEffect(() => {
if (!isConfigLoaded) return
Expand All @@ -139,7 +143,7 @@
cancelled = true
window.clearTimeout(timer)
}
}, [isConfigLoaded])

Check warning on line 146 in src/renderer/src/features/config/contexts/ConfigContext.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'config.installations' and 'getInstalledMods'. Either include them or remove the dependency array

// Paints the stored choice. Reads nothing but the config, so a launch with no network shows the
// chosen scene straight from the cache, or the bundled one when that file is not there.
Expand All @@ -156,7 +160,7 @@
const firstInstallation = config.installations[0]
if ((!config.lastUsedInstallation || !config.installations.some((i) => i.id === config.lastUsedInstallation)) && firstInstallation)
configDispatch({ type: CONFIG_ACTIONS.SET_LAST_USED_INSTALLATION, payload: firstInstallation.id })
}, [config.installations])

Check warning on line 163 in src/renderer/src/features/config/contexts/ConfigContext.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'config.lastUsedInstallation'. Either include it or remove the dependency array

// The list slices are handed out as-is: the reducer never rebuilds an array
// it did not change, so their identity already tracks their content. Only the
Expand All @@ -175,6 +179,8 @@
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,
Expand All @@ -191,6 +197,8 @@
config._backgroundRevision,
config.accentColor,
config.moddbVisibility,
config.modSuggestionsConsent,
config.dismissedModSuggestions,
config.receiveBetaUpdates,
config.measurePlaySessions,
config.allowBasicSessionStore,
Expand Down
Loading
Loading