From 2d035c861f0fb305ca8747b356e38a5e66dc8af5 Mon Sep 17 00:00:00 2001 From: cade Date: Tue, 7 Apr 2026 11:12:28 +0100 Subject: [PATCH 01/20] Added generateMergedSchema method and added global reactive properties for ecosystem. # Conflicts: # src/model/schema/ThunderstoreSchema.ts --- src/model/schema/ThunderstoreSchema.ts | 6 +++++- src/r2mm/ecosystem/EcosystemMerge.ts | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/r2mm/ecosystem/EcosystemMerge.ts diff --git a/src/model/schema/ThunderstoreSchema.ts b/src/model/schema/ThunderstoreSchema.ts index 60a62535b..77addf5e5 100644 --- a/src/model/schema/ThunderstoreSchema.ts +++ b/src/model/schema/ThunderstoreSchema.ts @@ -2,9 +2,10 @@ import Ajv from "ajv"; import addFormats from "ajv-formats"; import ecosystem from "../../assets/data/ecosystem.json"; -import { R2Modman as GameConfig, ThunderstoreEcosystem } from "../../assets/data/ecosystemTypes"; +import {ModloaderPackage, R2Modman as GameConfig, ThunderstoreEcosystem} from "../../assets/data/ecosystemTypes"; import jsonSchema from "../../assets/data/ecosystemJsonSchema.json"; import R2Error from "../errors/R2Error"; +import {ref} from "@vue/reactivity"; // Re-export generated types/Enums to avoid having the whole codebase // tightly coupled with the generated ecosystemTypes. @@ -16,6 +17,9 @@ export { Platform, } from "../../assets/data/ecosystemTypes"; +export const EcosystemSupportedGames = ref<[string, R2Modman][]>([]); +export const EcosystemModloaderPackages = ref([]); + export class EcosystemSchema { private static _isValidated: boolean = false; diff --git a/src/r2mm/ecosystem/EcosystemMerge.ts b/src/r2mm/ecosystem/EcosystemMerge.ts new file mode 100644 index 000000000..c91047552 --- /dev/null +++ b/src/r2mm/ecosystem/EcosystemMerge.ts @@ -0,0 +1,3 @@ +export async function generateMergedSchema(): Promise { + throw new Error("Not implemented."); +} From 21646d0fdf1ca68e02d460fde58559ff80e90ed3 Mon Sep 17 00:00:00 2001 From: cade Date: Wed, 8 Apr 2026 10:28:22 +0100 Subject: [PATCH 02/20] Added implementation to allow extending static json schema --- src/r2mm/ecosystem/EcosystemMerge.ts | 101 ++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/src/r2mm/ecosystem/EcosystemMerge.ts b/src/r2mm/ecosystem/EcosystemMerge.ts index c91047552..13e4ae2b3 100644 --- a/src/r2mm/ecosystem/EcosystemMerge.ts +++ b/src/r2mm/ecosystem/EcosystemMerge.ts @@ -1,3 +1,100 @@ -export async function generateMergedSchema(): Promise { - throw new Error("Not implemented."); +import bundledEcosystem from "../../assets/data/ecosystem.json"; +import {R2Modman, ThunderstoreEcosystem} from "../../assets/data/ecosystemTypes"; +import jsonSchema from "../../assets/data/ecosystemJsonSchema.json"; +import R2Error from "../../model/errors/R2Error"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import PathResolver from "../manager/PathResolver"; +import path from "../../providers/node/path/path"; +import FsProvider from "../../providers/generic/file/FsProvider"; +import VersionNumber from "../../model/VersionNumber"; +import ManagerInformation from "../../_managerinf/ManagerInformation"; +import {EcosystemModloaderPackages, EcosystemSupportedGames} from "../../model/schema/ThunderstoreSchema"; + +export type MergedThunderstoreEcosystem = ThunderstoreEcosystem & {version: string}; + +async function getMergedEcosystemPath(): Promise { + return path.join(PathResolver.MOD_ROOT, "ecosystem-merge.json"); +} + +async function updateLatestMergedEcosystemSchema(): Promise { + const bundledSchema = await loadBundledSchema(); + const latestSchema = await fetchLatestSchema(); + const mergedSchema: ThunderstoreEcosystem = { + schemaVersion: "", + communities: {}, + games: {}, + modloaderPackages: [], + packageInstallers: {}, + }; + mergedSchema.schemaVersion = latestSchema.schemaVersion; + mergedSchema.communities = { + ...bundledSchema.communities, + ...latestSchema.communities, + } + mergedSchema.games = { + ...bundledSchema.games, + ...latestSchema.games, + }; + // Convert to map to handle duplicate keys nicely + const modloaderMap = new Map( + [...bundledSchema.modloaderPackages, ...latestSchema.modloaderPackages] + .map(pkg => [pkg.packageId, pkg]) + ); + mergedSchema.modloaderPackages = [...modloaderMap.values()]; + mergedSchema.packageInstallers = { + ...bundledSchema.packageInstallers, + ...latestSchema.packageInstallers, + }; +} + +async function getLastSavedMergedEcosystemSchema(): Promise { + const contentBuffer = await FsProvider.instance.readFile(await getMergedEcosystemPath()); + const content = contentBuffer.toString("utf8"); + return JSON.parse(content); +} + +async function loadBundledSchema(): Promise { + const ajv = new Ajv(); + addFormats(ajv); + + const validate = ajv.compile(jsonSchema); + const isOk = validate(bundledEcosystem); + + if (!isOk) { + throw new R2Error("Schema validation error", ajv.errorsText(validate.errors)); + } + + return bundledEcosystem as ThunderstoreEcosystem; +} + +async function fetchLatestSchema(): Promise { + // TODO - Implement fetching of latest resource + return {} as ThunderstoreEcosystem; +} + +async function resolveMergedEcosystemSchema(): Promise { + const mergeFilePath = await getMergedEcosystemPath(); + const bundledSchema = async () => ({...(await loadBundledSchema()), version: ManagerInformation.VERSION.toString()}); + if (!(await FsProvider.instance.exists(mergeFilePath))) { + return bundledSchema(); + } + const content = await getLastSavedMergedEcosystemSchema(); + if (!new VersionNumber(content.version).isEqualTo(ManagerInformation.VERSION)) { + return bundledSchema(); + } + return content; +} + +export async function updateEcosystemReactives() { + const mergedSchema = await resolveMergedEcosystemSchema(); + const result: [string, R2Modman][] = [] + for (const [identifier, game] of Object.entries(mergedSchema.games)) { + if (game.r2modman == null) continue; + for (const entry of game.r2modman) { + result.push([identifier, entry]); + } + } + EcosystemSupportedGames.value = result; + EcosystemModloaderPackages.value = mergedSchema.modloaderPackages; } From 4f31a9abdfd17e4411a520a10e1db39954d3ab28 Mon Sep 17 00:00:00 2001 From: cade Date: Wed, 8 Apr 2026 11:09:15 +0100 Subject: [PATCH 03/20] GameSelectionScreen to start with bundled fallback and to load new options post-boot. - Start with bundled - Next, load from disk - Finally, should query live (not done yet) --- quasar.config.ts | 3 ++- src/boot/ecosystem.ts | 7 +++++++ src/model/game/GameManager.ts | 4 ++-- src/model/schema/ThunderstoreSchema.ts | 5 +++++ src/pages/GameSelectionScreen.vue | 10 ++++++--- src/r2mm/ecosystem/EcosystemMerge.ts | 28 +++++++++++++++++++++----- 6 files changed, 46 insertions(+), 11 deletions(-) create mode 100644 src/boot/ecosystem.ts diff --git a/quasar.config.ts b/quasar.config.ts index 886cf8f0d..12b51338c 100644 --- a/quasar.config.ts +++ b/quasar.config.ts @@ -18,7 +18,8 @@ export default defineConfig((ctx) => { boot: [ 'i18n', // 'axios', - 'floating-vue' + 'floating-vue', + 'ecosystem' ], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css diff --git a/src/boot/ecosystem.ts b/src/boot/ecosystem.ts new file mode 100644 index 000000000..cfe327aec --- /dev/null +++ b/src/boot/ecosystem.ts @@ -0,0 +1,7 @@ +import { defineBoot } from '#q-app/wrappers'; +import {EcosystemSchema} from "src/model/schema/ThunderstoreSchema"; + +// @ts-ignore +export default defineBoot(async ({ app }) => { + EcosystemSchema.init(); +}); diff --git a/src/model/game/GameManager.ts b/src/model/game/GameManager.ts index 757eef973..8f5196163 100644 --- a/src/model/game/GameManager.ts +++ b/src/model/game/GameManager.ts @@ -2,7 +2,7 @@ import Game from '../../model/game/Game'; import StorePlatformMetadata from '../../model/game/StorePlatformMetadata'; import PathResolver from '../../r2mm/manager/PathResolver'; import FileUtils from '../../utils/FileUtils'; -import { EcosystemSchema, Platform } from '../schema/ThunderstoreSchema'; +import {EcosystemSupportedGames, Platform} from '../schema/ThunderstoreSchema'; import path from '../../providers/node/path/path'; export default class GameManager { @@ -23,7 +23,7 @@ export default class GameManager { } static get gameList(): Game[] { - return EcosystemSchema.supportedGames.map(([identifier, game]) => new Game( + return EcosystemSupportedGames.value.map(([identifier, game]) => new Game( game.meta.displayName, game.internalFolderName, game.settingsIdentifier, diff --git a/src/model/schema/ThunderstoreSchema.ts b/src/model/schema/ThunderstoreSchema.ts index 77addf5e5..241f03410 100644 --- a/src/model/schema/ThunderstoreSchema.ts +++ b/src/model/schema/ThunderstoreSchema.ts @@ -23,6 +23,11 @@ export const EcosystemModloaderPackages = ref([]); export class EcosystemSchema { private static _isValidated: boolean = false; + public static init() { + EcosystemSupportedGames.value = this.supportedGames; + EcosystemModloaderPackages.value = this.modloaderPackages; + } + /** * Get a validated instance of the ecosystem schema. */ diff --git a/src/pages/GameSelectionScreen.vue b/src/pages/GameSelectionScreen.vue index 2221ea650..d4dc5dea7 100644 --- a/src/pages/GameSelectionScreen.vue +++ b/src/pages/GameSelectionScreen.vue @@ -189,6 +189,7 @@ import { getStore } from '../providers/generic/store/StoreProvider'; import { State } from '../store'; import { useRouter } from 'vue-router'; import ProtocolProvider from '../providers/generic/protocol/ProtocolProvider'; +import {updateEcosystemReactives, updateLatestMergedEcosystemSchema} from "src/r2mm/ecosystem/EcosystemMerge"; const store = getStore(); const router = useRouter(); @@ -203,7 +204,6 @@ const settings = ref(undefined); const isSettingDefaultPlatform = ref(false); const viewMode = ref(GameSelectionViewMode.LIST); const activeTab = ref(GameInstanceType.GAME); -const gameImages = reactive({}); const filteredGameList = computed(() => { const displayNameInAdditionalSearch = (game: Game, filterText: string): boolean => { @@ -369,6 +369,10 @@ onMounted(async () => { await store.dispatch('resetLocalState'); + updateEcosystemReactives() + // TODO - Allow updating latest schema + // .then(updateLatestMergedEcosystemSchema); + settings.value = await ManagerSettings.getSingleton(GameManager.defaultGame); const globalSettings = settings.value.getContext().global; favourites.value = globalSettings.favouriteGames || []; @@ -384,12 +388,12 @@ onMounted(async () => { } // Skip game selection view if valid default game & platform are set. - const {defaultGame, defaultPlatform} = ManagerUtils.getDefaults(settings.value); + const {defaultGame, defaultPlatform} = ManagerUtils.getDefaults(settings.value!); if (defaultGame && defaultPlatform) { selectedGame.value = defaultGame; selectedPlatform.value = defaultPlatform; - proceed(); + return proceed(); } }) diff --git a/src/r2mm/ecosystem/EcosystemMerge.ts b/src/r2mm/ecosystem/EcosystemMerge.ts index 13e4ae2b3..0f659ea5c 100644 --- a/src/r2mm/ecosystem/EcosystemMerge.ts +++ b/src/r2mm/ecosystem/EcosystemMerge.ts @@ -17,7 +17,7 @@ async function getMergedEcosystemPath(): Promise { return path.join(PathResolver.MOD_ROOT, "ecosystem-merge.json"); } -async function updateLatestMergedEcosystemSchema(): Promise { +export async function updateLatestMergedEcosystemSchema(): Promise { const bundledSchema = await loadBundledSchema(); const latestSchema = await fetchLatestSchema(); const mergedSchema: ThunderstoreEcosystem = { @@ -46,6 +46,17 @@ async function updateLatestMergedEcosystemSchema(): Promise { ...bundledSchema.packageInstallers, ...latestSchema.packageInstallers, }; + await writeLatestMergedEcosystemSchema(mergedSchema); + await internalUpdateEcosystemReactives(mergedSchema); +} + +async function writeLatestMergedEcosystemSchema(schema: ThunderstoreEcosystem): Promise { + const asMergedSchema: MergedThunderstoreEcosystem = { + ...schema, + version: ManagerInformation.VERSION.toString(), + }; + const writable = JSON.stringify(asMergedSchema); + return FsProvider.instance.writeFile(await getMergedEcosystemPath(), writable); } async function getLastSavedMergedEcosystemSchema(): Promise { @@ -86,15 +97,22 @@ async function resolveMergedEcosystemSchema(): Promise { const result: [string, R2Modman][] = [] - for (const [identifier, game] of Object.entries(mergedSchema.games)) { + for (const [identifier, game] of Object.entries(schema.games)) { if (game.r2modman == null) continue; for (const entry of game.r2modman) { result.push([identifier, entry]); } } EcosystemSupportedGames.value = result; - EcosystemModloaderPackages.value = mergedSchema.modloaderPackages; + EcosystemModloaderPackages.value = schema.modloaderPackages; +} + +export async function updateEcosystemReactives() { + const mergedSchema = await resolveMergedEcosystemSchema(); + await internalUpdateEcosystemReactives(mergedSchema); + return new Promise(resolve => { + setTimeout(resolve, 3000); + }); } From 0b5d93294a597bdc08518183b7dfb9cad2ce7e7f Mon Sep 17 00:00:00 2001 From: cade Date: Wed, 8 Apr 2026 12:59:25 +0100 Subject: [PATCH 04/20] Fixes and cleanup - Fixed error caused by fetchLatestSchema stub not including valid keys - Changed getMergedEcosystemPath to be more viable - Enabled updateLatestMergedEcosystemSchema call in GameSelectionScreen --- src/pages/GameSelectionScreen.vue | 3 +-- src/r2mm/ecosystem/EcosystemMerge.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/pages/GameSelectionScreen.vue b/src/pages/GameSelectionScreen.vue index d4dc5dea7..aecd762c5 100644 --- a/src/pages/GameSelectionScreen.vue +++ b/src/pages/GameSelectionScreen.vue @@ -370,8 +370,7 @@ onMounted(async () => { await store.dispatch('resetLocalState'); updateEcosystemReactives() - // TODO - Allow updating latest schema - // .then(updateLatestMergedEcosystemSchema); + .then(updateLatestMergedEcosystemSchema); settings.value = await ManagerSettings.getSingleton(GameManager.defaultGame); const globalSettings = settings.value.getContext().global; diff --git a/src/r2mm/ecosystem/EcosystemMerge.ts b/src/r2mm/ecosystem/EcosystemMerge.ts index 0f659ea5c..1badcc32f 100644 --- a/src/r2mm/ecosystem/EcosystemMerge.ts +++ b/src/r2mm/ecosystem/EcosystemMerge.ts @@ -14,7 +14,7 @@ import {EcosystemModloaderPackages, EcosystemSupportedGames} from "../../model/s export type MergedThunderstoreEcosystem = ThunderstoreEcosystem & {version: string}; async function getMergedEcosystemPath(): Promise { - return path.join(PathResolver.MOD_ROOT, "ecosystem-merge.json"); + return path.join(PathResolver.ROOT, "ecosystem-merge.json"); } export async function updateLatestMergedEcosystemSchema(): Promise { @@ -81,7 +81,13 @@ async function loadBundledSchema(): Promise { async function fetchLatestSchema(): Promise { // TODO - Implement fetching of latest resource - return {} as ThunderstoreEcosystem; + return { + schemaVersion: "", + communities: {}, + games: {}, + modloaderPackages: [], + packageInstallers: {}, + }; } async function resolveMergedEcosystemSchema(): Promise { From 207c3d201c829b7c419a9f8cb338d1b715188f13 Mon Sep 17 00:00:00 2001 From: cade Date: Wed, 8 Apr 2026 14:24:26 +0100 Subject: [PATCH 05/20] Removed bundle-only load at boot time, replaced with merge. - Also removed EcosystemSchema class as no longer needed - Replaced usages of EcosystemSchema getters with reactive equivalents. --- src/boot/ecosystem.ts | 9 ++- src/model/schema/ThunderstoreSchema.ts | 70 ++----------------- src/pages/GameSelectionScreen.vue | 3 +- src/r2mm/ecosystem/EcosystemMerge.ts | 3 - src/r2mm/installing/InstallationRules.ts | 4 +- .../ModLoaderVariantRecord.ts | 12 ++-- 6 files changed, 25 insertions(+), 76 deletions(-) diff --git a/src/boot/ecosystem.ts b/src/boot/ecosystem.ts index cfe327aec..136b596f0 100644 --- a/src/boot/ecosystem.ts +++ b/src/boot/ecosystem.ts @@ -1,7 +1,12 @@ import { defineBoot } from '#q-app/wrappers'; -import {EcosystemSchema} from "src/model/schema/ThunderstoreSchema"; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemMerge"; +import FsProvider from "src/providers/generic/file/FsProvider"; +import {NodeFsImplementation} from "src/providers/node/fs/NodeFsImplementation"; // @ts-ignore export default defineBoot(async ({ app }) => { - EcosystemSchema.init(); + FsProvider.provide(() => NodeFsImplementation); + await updateEcosystemReactives(); + // @ts-ignore + FsProvider.provide(() => undefined); }); diff --git a/src/model/schema/ThunderstoreSchema.ts b/src/model/schema/ThunderstoreSchema.ts index 241f03410..cc19d164b 100644 --- a/src/model/schema/ThunderstoreSchema.ts +++ b/src/model/schema/ThunderstoreSchema.ts @@ -1,10 +1,4 @@ -import Ajv from "ajv"; -import addFormats from "ajv-formats"; - -import ecosystem from "../../assets/data/ecosystem.json"; -import {ModloaderPackage, R2Modman as GameConfig, ThunderstoreEcosystem} from "../../assets/data/ecosystemTypes"; -import jsonSchema from "../../assets/data/ecosystemJsonSchema.json"; -import R2Error from "../errors/R2Error"; +import {ModloaderPackage, R2Modman as GameConfig} from "../../assets/data/ecosystemTypes"; import {ref} from "@vue/reactivity"; // Re-export generated types/Enums to avoid having the whole codebase @@ -17,63 +11,13 @@ export { Platform, } from "../../assets/data/ecosystemTypes"; -export const EcosystemSupportedGames = ref<[string, R2Modman][]>([]); +export const EcosystemSupportedGames = ref<[string, GameConfig][]>([]); export const EcosystemModloaderPackages = ref([]); -export class EcosystemSchema { - private static _isValidated: boolean = false; - - public static init() { - EcosystemSupportedGames.value = this.supportedGames; - EcosystemModloaderPackages.value = this.modloaderPackages; - } - - /** - * Get a validated instance of the ecosystem schema. - */ - private static get ecosystem(): ThunderstoreEcosystem { - if (this._isValidated) { - return ecosystem as ThunderstoreEcosystem; - } - - // Validate the schema via its schema schema. - const ajv = new Ajv(); - addFormats(ajv); - - const validate = ajv.compile(jsonSchema); - const isOk = validate(ecosystem); - - if (!isOk) { - throw new R2Error("Schema validation error", ajv.errorsText(validate.errors)); - } - - this._isValidated = true; - return ecosystem as ThunderstoreEcosystem; - } - - /** - * Get a list of [identifier, GameConfig] entries i.e. games supported by the mod manager. - */ - static get supportedGames() { - const result: [string, GameConfig][] = [] - for (const [identifier, game] of Object.entries(this.ecosystem.games)) { - if (game.r2modman == null) continue; - for (const entry of game.r2modman) { - result.push([identifier, entry]); - } - } - return result; - } - - static get modloaderPackages() { - return this.ecosystem.modloaderPackages; - } - - static getGameConfigBySettingsIdentifier(settingsIdentifier: string): GameConfig | undefined { - const config = this.supportedGames.find( - ([_id, config]) => config.settingsIdentifier === settingsIdentifier - ); +export function getGameConfigBySettingsIdentifier(settingsIdentifier: string): GameConfig | undefined { + const config = EcosystemSupportedGames.value.find( + ([_id, config]) => config.settingsIdentifier === settingsIdentifier + ); - return config ? config[1] : undefined; - } + return config ? config[1] : undefined; } diff --git a/src/pages/GameSelectionScreen.vue b/src/pages/GameSelectionScreen.vue index aecd762c5..1ae8cf588 100644 --- a/src/pages/GameSelectionScreen.vue +++ b/src/pages/GameSelectionScreen.vue @@ -369,8 +369,7 @@ onMounted(async () => { await store.dispatch('resetLocalState'); - updateEcosystemReactives() - .then(updateLatestMergedEcosystemSchema); + updateLatestMergedEcosystemSchema(); settings.value = await ManagerSettings.getSingleton(GameManager.defaultGame); const globalSettings = settings.value.getContext().global; diff --git a/src/r2mm/ecosystem/EcosystemMerge.ts b/src/r2mm/ecosystem/EcosystemMerge.ts index 1badcc32f..47e55d61c 100644 --- a/src/r2mm/ecosystem/EcosystemMerge.ts +++ b/src/r2mm/ecosystem/EcosystemMerge.ts @@ -118,7 +118,4 @@ async function internalUpdateEcosystemReactives(schema: ThunderstoreEcosystem): export async function updateEcosystemReactives() { const mergedSchema = await resolveMergedEcosystemSchema(); await internalUpdateEcosystemReactives(mergedSchema); - return new Promise(resolve => { - setTimeout(resolve, 3000); - }); } diff --git a/src/r2mm/installing/InstallationRules.ts b/src/r2mm/installing/InstallationRules.ts index ddfef5b0a..5a04a61bd 100644 --- a/src/r2mm/installing/InstallationRules.ts +++ b/src/r2mm/installing/InstallationRules.ts @@ -1,4 +1,4 @@ -import { EcosystemSchema, TrackingMethod } from '../../model/schema/ThunderstoreSchema'; +import {EcosystemSupportedGames, TrackingMethod} from '../../model/schema/ThunderstoreSchema'; import path from '../../providers/node/path/path'; export type CoreRuleType = { @@ -37,7 +37,7 @@ export default class InstallationRules { } public static apply() { - this._RULES = EcosystemSchema.supportedGames.map(([_, x]) => ({ + this._RULES = EcosystemSupportedGames.value.map(([_, x]) => ({ gameName: x.internalFolderName, rules: x.installRules, relativeFileExclusions: x.relativeFileExclusions, diff --git a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts index d79f1144d..3b375f5aa 100644 --- a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts +++ b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts @@ -1,11 +1,15 @@ import ModLoaderPackageMapping from '../../../model/installing/ModLoaderPackageMapping'; import VersionNumber from '../../../model/VersionNumber'; -import { EcosystemSchema, PackageLoader } from '../../../model/schema/ThunderstoreSchema'; +import { + EcosystemModloaderPackages, + EcosystemSupportedGames, + PackageLoader +} from '../../../model/schema/ThunderstoreSchema'; /** * A set of modloader packages read from the ecosystem schema. */ -export const MODLOADER_PACKAGES = EcosystemSchema.modloaderPackages.map((x) => +export const MODLOADER_PACKAGES = EcosystemModloaderPackages.value.map((x) => new ModLoaderPackageMapping( x.packageId, x.rootFolder, @@ -29,7 +33,7 @@ const OVERRIDES: Modloaders = { } export const MOD_LOADER_VARIANTS: Modloaders = Object.fromEntries( - EcosystemSchema.supportedGames + EcosystemSupportedGames.value .map(([_, game]) => [ game.internalFolderName, OVERRIDES[game.internalFolderName] || MODLOADER_PACKAGES @@ -37,7 +41,7 @@ export const MOD_LOADER_VARIANTS: Modloaders = Object.fromEntries( ); export const getModLoaderPackageNames = () => { - const deduplicated = new Set(EcosystemSchema.modloaderPackages.map((x) => x.packageId)); + const deduplicated = new Set(EcosystemModloaderPackages.value.map((x) => x.packageId)); const names = Array.from(deduplicated); names.sort(); return names; From e661b0cb4c360d4fbcf6b857f2c7de63dbfdf64a Mon Sep 17 00:00:00 2001 From: cade Date: Thu, 9 Apr 2026 08:09:01 +0100 Subject: [PATCH 06/20] Fixed tests --- src/r2mm/ecosystem/EcosystemMerge.ts | 2 ++ .../ModLoaderVariantRecord.ts | 33 +++++++++---------- .../GameDirectoryResolver.spec.ts | 13 +++++--- .../tests/unit/MelonLoader/state.spec.ts | 23 +++++++++---- .../tests/unit/ModLinker/ModLinker.spec.ts | 4 +++ .../tests/unit/Profile/Profile.ts.spec.ts | 2 ++ test/vitest/utils/InstallLogicUtils.ts | 2 ++ 7 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/r2mm/ecosystem/EcosystemMerge.ts b/src/r2mm/ecosystem/EcosystemMerge.ts index 47e55d61c..92adb53b4 100644 --- a/src/r2mm/ecosystem/EcosystemMerge.ts +++ b/src/r2mm/ecosystem/EcosystemMerge.ts @@ -10,6 +10,7 @@ import FsProvider from "../../providers/generic/file/FsProvider"; import VersionNumber from "../../model/VersionNumber"; import ManagerInformation from "../../_managerinf/ManagerInformation"; import {EcosystemModloaderPackages, EcosystemSupportedGames} from "../../model/schema/ThunderstoreSchema"; +import {updateModLoaderExports} from "../installing/profile_installers/ModLoaderVariantRecord"; export type MergedThunderstoreEcosystem = ThunderstoreEcosystem & {version: string}; @@ -113,6 +114,7 @@ async function internalUpdateEcosystemReactives(schema: ThunderstoreEcosystem): } EcosystemSupportedGames.value = result; EcosystemModloaderPackages.value = schema.modloaderPackages; + updateModLoaderExports(); } export async function updateEcosystemReactives() { diff --git a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts index 3b375f5aa..7823399e9 100644 --- a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts +++ b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts @@ -6,17 +6,6 @@ import { PackageLoader } from '../../../model/schema/ThunderstoreSchema'; -/** - * A set of modloader packages read from the ecosystem schema. - */ -export const MODLOADER_PACKAGES = EcosystemModloaderPackages.value.map((x) => - new ModLoaderPackageMapping( - x.packageId, - x.rootFolder, - x.loader, - ), -); - type Modloaders = Record; // Overrides are needed as the "recommended version" information @@ -32,13 +21,21 @@ const OVERRIDES: Modloaders = { ], } -export const MOD_LOADER_VARIANTS: Modloaders = Object.fromEntries( - EcosystemSupportedGames.value - .map(([_, game]) => [ - game.internalFolderName, - OVERRIDES[game.internalFolderName] || MODLOADER_PACKAGES - ]) -); +export let MODLOADER_PACKAGES: ModLoaderPackageMapping[] = []; +export let MOD_LOADER_VARIANTS: Modloaders = {}; + +export function updateModLoaderExports() { + MODLOADER_PACKAGES = EcosystemModloaderPackages.value.map((x) => + new ModLoaderPackageMapping(x.packageId, x.rootFolder, x.loader) + ); + MOD_LOADER_VARIANTS = Object.fromEntries( + EcosystemSupportedGames.value + .map(([_, game]) => [ + game.internalFolderName, + OVERRIDES[game.internalFolderName] || MODLOADER_PACKAGES + ]) + ); +} export const getModLoaderPackageNames = () => { const deduplicated = new Set(EcosystemModloaderPackages.value.map((x) => x.packageId)); diff --git a/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts b/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts index 49e46bc81..7a6372df8 100644 --- a/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts +++ b/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from 'vitest'; +import {beforeAll, beforeEach, describe, expect, test} from 'vitest'; import path from 'path'; import FsProvider from '../../../../../src/providers/generic/file/FsProvider'; import GameManager from '../../../../../src/model/game/GameManager'; @@ -6,16 +6,21 @@ import GameDirectoryResolverImpl from '../../../../../src/r2mm/manager/win32/Gam import InMemoryFsProvider from '../../../stubs/providers/InMemory.FsProvider'; import { providePathImplementation } from '../../../../../src/providers/node/path/path'; import { TestPathProvider } from '../../../stubs/providers/node/Node.Path.Provider'; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemMerge"; describe.skipIf(process.platform !== 'win32')('GameDirectoryResolver', () => { const steamRoot = 'TEST_STEAM_PATH'; let resolver: GameDirectoryResolverImpl; - beforeEach(async () => { - providePathImplementation(() => TestPathProvider); + beforeAll(async () => { const inMemoryFs = new InMemoryFsProvider(); FsProvider.provide(() => inMemoryFs); InMemoryFsProvider.clear(); + providePathImplementation(() => TestPathProvider); + await updateEcosystemReactives(); + }) + + beforeEach(async () => { resolver = new GameDirectoryResolverImpl(); await FsProvider.instance.mkdirs(path.join(steamRoot, 'steamapps', 'common')); }); @@ -55,4 +60,4 @@ describe.skipIf(process.platform !== 'win32')('GameDirectoryResolver', () => { expect(result).toBe(path.join(steamRoot, 'steamapps', 'common', 'Very Very Valet', 'windows')); }); -}); \ No newline at end of file +}); diff --git a/test/vitest/tests/unit/MelonLoader/state.spec.ts b/test/vitest/tests/unit/MelonLoader/state.spec.ts index a41bc7ec3..35b4071d2 100644 --- a/test/vitest/tests/unit/MelonLoader/state.spec.ts +++ b/test/vitest/tests/unit/MelonLoader/state.spec.ts @@ -13,10 +13,12 @@ import GenericProfileInstaller from '../../../../../src/r2mm/installing/profile_ import GameManager from '../../../../../src/model/game/GameManager'; import ConflictManagementProvider from '../../../../../src/providers/generic/installing/ConflictManagementProvider'; import { addToStateFile } from '../../../../../src/installers/InstallRulePluginInstaller'; -import { describe, beforeEach, afterEach, test, expect } from 'vitest'; +import {describe, beforeEach, afterEach, test, expect, beforeAll} from 'vitest'; import {providePathImplementation} from "../../../../../src/providers/node/path/path"; import {TestPathProvider} from "../../../stubs/providers/node/Node.Path.Provider"; import StubProfileProvider from '../../../stubs/providers/stub.ProfileProvider'; +import {updateEcosystemReactives} from "../../../../../src/r2mm/ecosystem/EcosystemMerge"; +import InMemoryFsProvider from "../../../stubs/providers/InMemory.FsProvider"; providePathImplementation(() => TestPathProvider); @@ -41,6 +43,13 @@ describe("State testing", () => { describe("State file", () => { + beforeAll(async () => { + const inMemoryFs = new InMemoryFsProvider(); + FsProvider.provide(() => inMemoryFs); + InMemoryFsProvider.clear(); + await updateEcosystemReactives(); + }) + beforeEach(beforeSetup); afterEach(() => { sandbox.restore(); @@ -66,7 +75,7 @@ describe("State testing", () => { profile ); - const out = fsStub.writeFile.args[0][1] as unknown as string; + const out = fsStub.writeFile.args[0]![1] as unknown as string; const parsed = yaml.parse(out) as ModFileTracker; expect(parsed.modName).toBe(fakeMod.getName()); // JSON serialization for speed comparison @@ -135,7 +144,7 @@ describe("State testing", () => { const outputState = await processConflictManagement(modA, modFileTrackerA, modB, modFileTrackerB); - expect(outputState.currentState.join(",")).toBe([[modFileTrackerA.files[0][1], modA.getName()], [modFileTrackerB.files[0][1], modB.getName()]].join(",")); + expect(outputState.currentState.join(",")).toBe([[modFileTrackerA.files[0]![1], modA.getName()], [modFileTrackerB.files[0]![1], modB.getName()]].join(",")); }); @@ -159,7 +168,7 @@ describe("State testing", () => { const outputState = await processConflictManagement(modA, modFileTrackerA, modB, modFileTrackerB); - expect(outputState.currentState.join(",")).toBe([[modFileTrackerB.files[0][1], modB.getName()]].join(",")); + expect(outputState.currentState.join(",")).toBe([[modFileTrackerB.files[0]![1], modB.getName()]].join(",")); }); // If a mod is disabled, the mod files should not exist if highest priority for those files. @@ -182,7 +191,7 @@ describe("State testing", () => { const outputState = await processConflictManagement(modA, modFileTrackerA, modB, modFileTrackerB); - expect(outputState.currentState.join(",")).toBe([[`${modFileTrackerA.files[0][1]}.manager.disabled`, modA.getName()], [modFileTrackerB.files[0][1], modB.getName()]].join(",")); + expect(outputState.currentState.join(",")).toBe([[`${modFileTrackerA.files[0]![1]}.manager.disabled`, modA.getName()], [modFileTrackerB.files[0]![1], modB.getName()]].join(",")); }); // If a mod is disabled, any lower priority files should not be overwritten. @@ -205,7 +214,7 @@ describe("State testing", () => { const outputState = await processConflictManagement(modA, modFileTrackerA, modB, modFileTrackerB); - expect(outputState.currentState.join(",")).toBe([[modFileTrackerA.files[0][1], modA.getName()], [`${modFileTrackerB.files[0][1]}.manager.disabled`, modB.getName()]].join(",")); + expect(outputState.currentState.join(",")).toBe([[modFileTrackerA.files[0]![1], modA.getName()], [`${modFileTrackerB.files[0]![1]}.manager.disabled`, modB.getName()]].join(",")); }); }); @@ -247,5 +256,5 @@ let processConflictManagement = async (modA: ManifestV2, modFileTrackerA: ModFil await conflictManagement.resolveConflicts([modA, modB], profile.asImmutableProfile()); - return yaml.parse(fsStub.writeFile.args[0][1] as unknown as string) as StateTracker; + return yaml.parse(fsStub.writeFile.args[0]![1] as unknown as string) as StateTracker; } diff --git a/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts b/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts index c3a0f72e2..0db1c0a82 100644 --- a/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts +++ b/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts @@ -16,6 +16,7 @@ import { providePathImplementation } from '../../../../../src/providers/node/pat import { TestPathProvider } from '../../../stubs/providers/node/Node.Path.Provider'; import { provideAppWindowImplementation } from '../../../../../src/providers/node/app/app_window'; import { TestAppWindowProvider } from '../../../stubs/providers/node/AppWindow.Provider'; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemMerge"; class ProfileProviderImpl extends ProfileProvider { ensureProfileDirectory(directory: string, profile: string): void { @@ -36,6 +37,9 @@ describe.skipIf(process.platform !== 'win32')('ModLinker', async () => { const inMemoryFs = new InMemoryFsProvider(); FsProvider.provide(() => inMemoryFs); InMemoryFsProvider.clear(); + + await updateEcosystemReactives(); + PathResolver.MOD_ROOT = 'MODS'; await inMemoryFs.mkdirs(PathResolver.MOD_ROOT); ProfileProvider.provide(() => new ProfileProviderImpl()); diff --git a/test/vitest/tests/unit/Profile/Profile.ts.spec.ts b/test/vitest/tests/unit/Profile/Profile.ts.spec.ts index 3e305bf88..b96c17f15 100644 --- a/test/vitest/tests/unit/Profile/Profile.ts.spec.ts +++ b/test/vitest/tests/unit/Profile/Profile.ts.spec.ts @@ -9,6 +9,7 @@ import PathResolver from '../../../../../src/r2mm/manager/PathResolver'; import { beforeAll, describe, expect, test } from 'vitest'; import { providePathImplementation } from '../../../../../src/providers/node/path/path'; import { TestPathProvider } from '../../../stubs/providers/node/Node.Path.Provider'; +import { updateEcosystemReactives } from "src/r2mm/ecosystem/EcosystemMerge"; class ProfileProviderImpl extends ProfileProvider { @@ -34,6 +35,7 @@ describe("ImmutableProfile", () => { FsProvider.provide(() => inMemoryFs); ProfileProvider.provide(() => new ProfileProviderImpl()); InMemoryFsProvider.clear(); + await updateEcosystemReactives(); }); test("Initializing ImmutableProfile doesn't affect Profile", () => { diff --git a/test/vitest/utils/InstallLogicUtils.ts b/test/vitest/utils/InstallLogicUtils.ts index b93023435..f1807e6e1 100644 --- a/test/vitest/utils/InstallLogicUtils.ts +++ b/test/vitest/utils/InstallLogicUtils.ts @@ -19,6 +19,7 @@ import PathResolver from '../../../src/r2mm/manager/PathResolver'; import {providePathImplementation} from "../../../src/providers/node/path/path"; import {TestPathProvider} from "../stubs/providers/node/Node.Path.Provider"; import {expect} from 'vitest'; +import {updateEcosystemReactives} from "../../../src/r2mm/ecosystem/EcosystemMerge"; class ProfileProviderImpl extends ProfileProvider { ensureProfileDirectory(directory: string, profile: string): void { @@ -36,6 +37,7 @@ export async function installLogicBeforeEach(internalFolderName: string) { providePathImplementation(() => TestPathProvider); const inMemoryFs = new InMemoryFsProvider(); FsProvider.provide(() => inMemoryFs); + await updateEcosystemReactives(); InMemoryFsProvider.clear(); PathResolver.MOD_ROOT = 'MODS'; await inMemoryFs.mkdirs(PathResolver.MOD_ROOT); From 61f61c2ea34cc3bd048b39b27bc842cee55dd8d3 Mon Sep 17 00:00:00 2001 From: cade Date: Thu, 9 Apr 2026 09:11:13 +0100 Subject: [PATCH 07/20] Added tests for EcosystemMerge --- .../EcosystemMerge/EcosystemMerge.spec.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts diff --git a/test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts b/test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts new file mode 100644 index 000000000..0afa18559 --- /dev/null +++ b/test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts @@ -0,0 +1,136 @@ +import * as path from 'path'; +import {beforeEach, describe, expect, test} from 'vitest'; + +import {MergedThunderstoreEcosystem, updateEcosystemReactives, updateLatestMergedEcosystemSchema} from '../../../../../src/r2mm/ecosystem/EcosystemMerge'; +import {EcosystemModloaderPackages, EcosystemSupportedGames} from '../../../../../src/model/schema/ThunderstoreSchema'; +import {MODLOADER_PACKAGES, MOD_LOADER_VARIANTS, updateModLoaderExports} from '../../../../../src/r2mm/installing/profile_installers/ModLoaderVariantRecord'; +import InMemoryFsProvider from '../../../stubs/providers/InMemory.FsProvider'; +import FsProvider from '../../../../../src/providers/generic/file/FsProvider'; +import PathResolver from '../../../../../src/r2mm/manager/PathResolver'; +import {providePathImplementation} from '../../../../../src/providers/node/path/path'; +import {TestPathProvider} from '../../../stubs/providers/node/Node.Path.Provider'; +import ManagerInformation from '../../../../../src/_managerinf/ManagerInformation'; + +const TEST_ROOT = 'TEST_ROOT'; +const mergeFilePath = path.join(TEST_ROOT, 'ecosystem-merge.json'); + +async function writeCacheFile(schema: Partial) { + const full: MergedThunderstoreEcosystem = { + schemaVersion: '0.0.0', + communities: {}, + games: {}, + modloaderPackages: [], + packageInstallers: {}, + version: ManagerInformation.VERSION.toString(), + ...schema, + }; + await FsProvider.instance.writeFile(mergeFilePath, JSON.stringify(full)); +} + +describe('EcosystemMerge', () => { + + beforeEach(async () => { + providePathImplementation(() => TestPathProvider); + InMemoryFsProvider.clear(); + FsProvider.provide(() => new InMemoryFsProvider()); + PathResolver.ROOT = TEST_ROOT; + EcosystemSupportedGames.value = []; + EcosystemModloaderPackages.value = []; + updateModLoaderExports(); + await FsProvider.instance.mkdirs(TEST_ROOT); + }); + + describe('updateEcosystemReactives', () => { + + test('falls back to bundled schema when no cache file exists', async () => { + expect(EcosystemSupportedGames.value).toHaveLength(0); + expect(EcosystemModloaderPackages.value).toHaveLength(0); + + await updateEcosystemReactives(); + + expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); + expect(EcosystemModloaderPackages.value.length).toBeGreaterThan(0); + }); + + test('loads from cache when version matches', async () => { + expect(EcosystemSupportedGames.value).toHaveLength(0); + await writeCacheFile({games: {}, modloaderPackages: []}); + + await updateEcosystemReactives(); + + expect(EcosystemSupportedGames.value).toHaveLength(0); + }); + + test('falls back to bundled schema when cached version is stale', async () => { + expect(EcosystemSupportedGames.value).toHaveLength(0); + await writeCacheFile({version: '0.0.0'}); + + await updateEcosystemReactives(); + + expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); + }); + + test('populates mod loader exports after updating reactives', async () => { + expect(MODLOADER_PACKAGES.length).toBe(0); + expect(Object.keys(MOD_LOADER_VARIANTS).length).toBe(0); + + await updateEcosystemReactives(); + + expect(MODLOADER_PACKAGES.length).toBeGreaterThan(0); + expect(Object.keys(MOD_LOADER_VARIANTS).length).toBeGreaterThan(0); + }); + + }); + + describe('updateLatestMergedEcosystemSchema', () => { + + test('writes merge file to disk', async () => { + expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); + + await updateLatestMergedEcosystemSchema(); + + expect(await FsProvider.instance.exists(mergeFilePath)).toBe(true); + }); + + test('written file contains current version', async () => { + expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); + + await updateLatestMergedEcosystemSchema(); + + const content = (await FsProvider.instance.readFile(mergeFilePath)).toString('utf8'); + const parsed: MergedThunderstoreEcosystem = JSON.parse(content); + expect(parsed.version).toBe(ManagerInformation.VERSION.toString()); + }); + + test('bundled games are preserved when latest schema is empty', async () => { + expect(EcosystemSupportedGames.value).toHaveLength(0); + + await updateLatestMergedEcosystemSchema(); + + expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); + }); + + test('games absent from latest schema are preserved from bundled', async () => { + await writeCacheFile({games: {}, modloaderPackages: []}); + await updateEcosystemReactives(); + expect(EcosystemSupportedGames.value.find(([_, g]) => g.internalFolderName === 'RiskOfRain2')).toBeUndefined(); + + await updateLatestMergedEcosystemSchema(); + + const game = EcosystemSupportedGames.value + .find(([_, game]) => game.internalFolderName === 'RiskOfRain2'); + expect(game).toBeDefined(); + }); + + test('overwrites existing cache file', async () => { + await writeCacheFile({games: {}, modloaderPackages: []}); + expect(EcosystemSupportedGames.value).toHaveLength(0); + + await updateLatestMergedEcosystemSchema(); + + expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); + }); + + }); + +}); From 781013b30b3280c93bc7fc9f033ff972a563c3fa Mon Sep 17 00:00:00 2001 From: cade Date: Thu, 9 Apr 2026 09:13:32 +0100 Subject: [PATCH 08/20] Improved coverage of a few test scenarios --- .../vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts b/test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts index 0afa18559..2c04e364d 100644 --- a/test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts +++ b/test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts @@ -45,19 +45,23 @@ describe('EcosystemMerge', () => { test('falls back to bundled schema when no cache file exists', async () => { expect(EcosystemSupportedGames.value).toHaveLength(0); expect(EcosystemModloaderPackages.value).toHaveLength(0); + expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); await updateEcosystemReactives(); + expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); expect(EcosystemModloaderPackages.value.length).toBeGreaterThan(0); }); test('loads from cache when version matches', async () => { expect(EcosystemSupportedGames.value).toHaveLength(0); - await writeCacheFile({games: {}, modloaderPackages: []}); + expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); + await writeCacheFile({games: {}, modloaderPackages: []}); await updateEcosystemReactives(); + expect(await FsProvider.instance.exists(mergeFilePath)).toBe(true); expect(EcosystemSupportedGames.value).toHaveLength(0); }); From 40e0721634e8e03c0e129858c56857de5d980067 Mon Sep 17 00:00:00 2001 From: cade Date: Thu, 9 Apr 2026 13:06:16 +0100 Subject: [PATCH 09/20] Removed "merge" functionality and renamed where appropriate --- src/boot/ecosystem.ts | 2 +- src/pages/GameSelectionScreen.vue | 4 +- .../{EcosystemMerge.ts => EcosystemSchema.ts} | 49 +++----------- .../EcosystemSchema.spec.ts} | 67 ++++++------------- .../GameDirectoryResolver.spec.ts | 2 +- .../tests/unit/MelonLoader/state.spec.ts | 2 +- .../tests/unit/ModLinker/ModLinker.spec.ts | 2 +- .../tests/unit/Profile/Profile.ts.spec.ts | 2 +- test/vitest/utils/InstallLogicUtils.ts | 2 +- 9 files changed, 38 insertions(+), 94 deletions(-) rename src/r2mm/ecosystem/{EcosystemMerge.ts => EcosystemSchema.ts} (63%) rename test/vitest/tests/unit/{EcosystemMerge/EcosystemMerge.spec.ts => EcosystemSchema/EcosystemSchema.spec.ts} (56%) diff --git a/src/boot/ecosystem.ts b/src/boot/ecosystem.ts index 136b596f0..ccf147c5c 100644 --- a/src/boot/ecosystem.ts +++ b/src/boot/ecosystem.ts @@ -1,5 +1,5 @@ import { defineBoot } from '#q-app/wrappers'; -import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemMerge"; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemSchema"; import FsProvider from "src/providers/generic/file/FsProvider"; import {NodeFsImplementation} from "src/providers/node/fs/NodeFsImplementation"; diff --git a/src/pages/GameSelectionScreen.vue b/src/pages/GameSelectionScreen.vue index 1ae8cf588..72890dd7d 100644 --- a/src/pages/GameSelectionScreen.vue +++ b/src/pages/GameSelectionScreen.vue @@ -189,7 +189,7 @@ import { getStore } from '../providers/generic/store/StoreProvider'; import { State } from '../store'; import { useRouter } from 'vue-router'; import ProtocolProvider from '../providers/generic/protocol/ProtocolProvider'; -import {updateEcosystemReactives, updateLatestMergedEcosystemSchema} from "src/r2mm/ecosystem/EcosystemMerge"; +import {updateEcosystemReactives, updateLatestEcosystemSchema} from "src/r2mm/ecosystem/EcosystemSchema"; const store = getStore(); const router = useRouter(); @@ -369,7 +369,7 @@ onMounted(async () => { await store.dispatch('resetLocalState'); - updateLatestMergedEcosystemSchema(); + updateLatestEcosystemSchema(); settings.value = await ManagerSettings.getSingleton(GameManager.defaultGame); const globalSettings = settings.value.getContext().global; diff --git a/src/r2mm/ecosystem/EcosystemMerge.ts b/src/r2mm/ecosystem/EcosystemSchema.ts similarity index 63% rename from src/r2mm/ecosystem/EcosystemMerge.ts rename to src/r2mm/ecosystem/EcosystemSchema.ts index 92adb53b4..6678a9d24 100644 --- a/src/r2mm/ecosystem/EcosystemMerge.ts +++ b/src/r2mm/ecosystem/EcosystemSchema.ts @@ -12,47 +12,20 @@ import ManagerInformation from "../../_managerinf/ManagerInformation"; import {EcosystemModloaderPackages, EcosystemSupportedGames} from "../../model/schema/ThunderstoreSchema"; import {updateModLoaderExports} from "../installing/profile_installers/ModLoaderVariantRecord"; -export type MergedThunderstoreEcosystem = ThunderstoreEcosystem & {version: string}; +export type VersionedThunderstoreEcosystem = ThunderstoreEcosystem & {version: string}; async function getMergedEcosystemPath(): Promise { - return path.join(PathResolver.ROOT, "ecosystem-merge.json"); + return path.join(PathResolver.ROOT, "latest-ecosystem-schema.json"); } -export async function updateLatestMergedEcosystemSchema(): Promise { - const bundledSchema = await loadBundledSchema(); +export async function updateLatestEcosystemSchema(): Promise { const latestSchema = await fetchLatestSchema(); - const mergedSchema: ThunderstoreEcosystem = { - schemaVersion: "", - communities: {}, - games: {}, - modloaderPackages: [], - packageInstallers: {}, - }; - mergedSchema.schemaVersion = latestSchema.schemaVersion; - mergedSchema.communities = { - ...bundledSchema.communities, - ...latestSchema.communities, - } - mergedSchema.games = { - ...bundledSchema.games, - ...latestSchema.games, - }; - // Convert to map to handle duplicate keys nicely - const modloaderMap = new Map( - [...bundledSchema.modloaderPackages, ...latestSchema.modloaderPackages] - .map(pkg => [pkg.packageId, pkg]) - ); - mergedSchema.modloaderPackages = [...modloaderMap.values()]; - mergedSchema.packageInstallers = { - ...bundledSchema.packageInstallers, - ...latestSchema.packageInstallers, - }; - await writeLatestMergedEcosystemSchema(mergedSchema); - await internalUpdateEcosystemReactives(mergedSchema); + await writeLatestEcosystemSchema(latestSchema); + await internalUpdateEcosystemReactives(latestSchema); } -async function writeLatestMergedEcosystemSchema(schema: ThunderstoreEcosystem): Promise { - const asMergedSchema: MergedThunderstoreEcosystem = { +async function writeLatestEcosystemSchema(schema: ThunderstoreEcosystem): Promise { + const asMergedSchema: VersionedThunderstoreEcosystem = { ...schema, version: ManagerInformation.VERSION.toString(), }; @@ -60,7 +33,7 @@ async function writeLatestMergedEcosystemSchema(schema: ThunderstoreEcosystem): return FsProvider.instance.writeFile(await getMergedEcosystemPath(), writable); } -async function getLastSavedMergedEcosystemSchema(): Promise { +async function getLastSavedEcosystemSchema(): Promise { const contentBuffer = await FsProvider.instance.readFile(await getMergedEcosystemPath()); const content = contentBuffer.toString("utf8"); return JSON.parse(content); @@ -91,13 +64,13 @@ async function fetchLatestSchema(): Promise { }; } -async function resolveMergedEcosystemSchema(): Promise { +async function resolveCachedEcosystemSchema(): Promise { const mergeFilePath = await getMergedEcosystemPath(); const bundledSchema = async () => ({...(await loadBundledSchema()), version: ManagerInformation.VERSION.toString()}); if (!(await FsProvider.instance.exists(mergeFilePath))) { return bundledSchema(); } - const content = await getLastSavedMergedEcosystemSchema(); + const content = await getLastSavedEcosystemSchema(); if (!new VersionNumber(content.version).isEqualTo(ManagerInformation.VERSION)) { return bundledSchema(); } @@ -118,6 +91,6 @@ async function internalUpdateEcosystemReactives(schema: ThunderstoreEcosystem): } export async function updateEcosystemReactives() { - const mergedSchema = await resolveMergedEcosystemSchema(); + const mergedSchema = await resolveCachedEcosystemSchema(); await internalUpdateEcosystemReactives(mergedSchema); } diff --git a/test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts similarity index 56% rename from test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts rename to test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts index 2c04e364d..d479fb911 100644 --- a/test/vitest/tests/unit/EcosystemMerge/EcosystemMerge.spec.ts +++ b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import {beforeEach, describe, expect, test} from 'vitest'; -import {MergedThunderstoreEcosystem, updateEcosystemReactives, updateLatestMergedEcosystemSchema} from '../../../../../src/r2mm/ecosystem/EcosystemMerge'; +import {VersionedThunderstoreEcosystem, updateEcosystemReactives, updateLatestEcosystemSchema} from 'src/r2mm/ecosystem/EcosystemSchema'; import {EcosystemModloaderPackages, EcosystemSupportedGames} from '../../../../../src/model/schema/ThunderstoreSchema'; import {MODLOADER_PACKAGES, MOD_LOADER_VARIANTS, updateModLoaderExports} from '../../../../../src/r2mm/installing/profile_installers/ModLoaderVariantRecord'; import InMemoryFsProvider from '../../../stubs/providers/InMemory.FsProvider'; @@ -12,10 +12,10 @@ import {TestPathProvider} from '../../../stubs/providers/node/Node.Path.Provider import ManagerInformation from '../../../../../src/_managerinf/ManagerInformation'; const TEST_ROOT = 'TEST_ROOT'; -const mergeFilePath = path.join(TEST_ROOT, 'ecosystem-merge.json'); +const latestSchemaFilePath = path.join(TEST_ROOT, 'latest-ecosystem-schema.json'); -async function writeCacheFile(schema: Partial) { - const full: MergedThunderstoreEcosystem = { +async function writeCacheFile(schema: Partial) { + const full: VersionedThunderstoreEcosystem = { schemaVersion: '0.0.0', communities: {}, games: {}, @@ -24,10 +24,10 @@ async function writeCacheFile(schema: Partial) { version: ManagerInformation.VERSION.toString(), ...schema, }; - await FsProvider.instance.writeFile(mergeFilePath, JSON.stringify(full)); + await FsProvider.instance.writeFile(latestSchemaFilePath, JSON.stringify(full)); } -describe('EcosystemMerge', () => { +describe('EcosystemSchema', () => { beforeEach(async () => { providePathImplementation(() => TestPathProvider); @@ -45,23 +45,23 @@ describe('EcosystemMerge', () => { test('falls back to bundled schema when no cache file exists', async () => { expect(EcosystemSupportedGames.value).toHaveLength(0); expect(EcosystemModloaderPackages.value).toHaveLength(0); - expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); await updateEcosystemReactives(); - expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); expect(EcosystemModloaderPackages.value.length).toBeGreaterThan(0); }); test('loads from cache when version matches', async () => { expect(EcosystemSupportedGames.value).toHaveLength(0); - expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); await writeCacheFile({games: {}, modloaderPackages: []}); await updateEcosystemReactives(); - expect(await FsProvider.instance.exists(mergeFilePath)).toBe(true); + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(true); expect(EcosystemSupportedGames.value).toHaveLength(0); }); @@ -86,55 +86,26 @@ describe('EcosystemMerge', () => { }); - describe('updateLatestMergedEcosystemSchema', () => { + describe('updateLatestEcosystemSchema', () => { - test('writes merge file to disk', async () => { - expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); + test('writes file to disk', async () => { + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); - await updateLatestMergedEcosystemSchema(); + await updateLatestEcosystemSchema(); - expect(await FsProvider.instance.exists(mergeFilePath)).toBe(true); + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(true); }); test('written file contains current version', async () => { - expect(await FsProvider.instance.exists(mergeFilePath)).toBe(false); + expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); - await updateLatestMergedEcosystemSchema(); + await updateLatestEcosystemSchema(); - const content = (await FsProvider.instance.readFile(mergeFilePath)).toString('utf8'); - const parsed: MergedThunderstoreEcosystem = JSON.parse(content); + const content = (await FsProvider.instance.readFile(latestSchemaFilePath)).toString('utf8'); + const parsed: VersionedThunderstoreEcosystem = JSON.parse(content); expect(parsed.version).toBe(ManagerInformation.VERSION.toString()); }); - test('bundled games are preserved when latest schema is empty', async () => { - expect(EcosystemSupportedGames.value).toHaveLength(0); - - await updateLatestMergedEcosystemSchema(); - - expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); - }); - - test('games absent from latest schema are preserved from bundled', async () => { - await writeCacheFile({games: {}, modloaderPackages: []}); - await updateEcosystemReactives(); - expect(EcosystemSupportedGames.value.find(([_, g]) => g.internalFolderName === 'RiskOfRain2')).toBeUndefined(); - - await updateLatestMergedEcosystemSchema(); - - const game = EcosystemSupportedGames.value - .find(([_, game]) => game.internalFolderName === 'RiskOfRain2'); - expect(game).toBeDefined(); - }); - - test('overwrites existing cache file', async () => { - await writeCacheFile({games: {}, modloaderPackages: []}); - expect(EcosystemSupportedGames.value).toHaveLength(0); - - await updateLatestMergedEcosystemSchema(); - - expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); - }); - }); }); diff --git a/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts b/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts index 7a6372df8..d62a0a174 100644 --- a/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts +++ b/test/vitest/tests/unit/GameDirectoryResolver/GameDirectoryResolver.spec.ts @@ -6,7 +6,7 @@ import GameDirectoryResolverImpl from '../../../../../src/r2mm/manager/win32/Gam import InMemoryFsProvider from '../../../stubs/providers/InMemory.FsProvider'; import { providePathImplementation } from '../../../../../src/providers/node/path/path'; import { TestPathProvider } from '../../../stubs/providers/node/Node.Path.Provider'; -import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemMerge"; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemSchema"; describe.skipIf(process.platform !== 'win32')('GameDirectoryResolver', () => { const steamRoot = 'TEST_STEAM_PATH'; diff --git a/test/vitest/tests/unit/MelonLoader/state.spec.ts b/test/vitest/tests/unit/MelonLoader/state.spec.ts index 35b4071d2..4d4d364c5 100644 --- a/test/vitest/tests/unit/MelonLoader/state.spec.ts +++ b/test/vitest/tests/unit/MelonLoader/state.spec.ts @@ -17,7 +17,7 @@ import {describe, beforeEach, afterEach, test, expect, beforeAll} from 'vitest'; import {providePathImplementation} from "../../../../../src/providers/node/path/path"; import {TestPathProvider} from "../../../stubs/providers/node/Node.Path.Provider"; import StubProfileProvider from '../../../stubs/providers/stub.ProfileProvider'; -import {updateEcosystemReactives} from "../../../../../src/r2mm/ecosystem/EcosystemMerge"; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemSchema"; import InMemoryFsProvider from "../../../stubs/providers/InMemory.FsProvider"; providePathImplementation(() => TestPathProvider); diff --git a/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts b/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts index 0db1c0a82..e1a9151f6 100644 --- a/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts +++ b/test/vitest/tests/unit/ModLinker/ModLinker.spec.ts @@ -16,7 +16,7 @@ import { providePathImplementation } from '../../../../../src/providers/node/pat import { TestPathProvider } from '../../../stubs/providers/node/Node.Path.Provider'; import { provideAppWindowImplementation } from '../../../../../src/providers/node/app/app_window'; import { TestAppWindowProvider } from '../../../stubs/providers/node/AppWindow.Provider'; -import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemMerge"; +import {updateEcosystemReactives} from "src/r2mm/ecosystem/EcosystemSchema"; class ProfileProviderImpl extends ProfileProvider { ensureProfileDirectory(directory: string, profile: string): void { diff --git a/test/vitest/tests/unit/Profile/Profile.ts.spec.ts b/test/vitest/tests/unit/Profile/Profile.ts.spec.ts index b96c17f15..414dd9b9d 100644 --- a/test/vitest/tests/unit/Profile/Profile.ts.spec.ts +++ b/test/vitest/tests/unit/Profile/Profile.ts.spec.ts @@ -9,7 +9,7 @@ import PathResolver from '../../../../../src/r2mm/manager/PathResolver'; import { beforeAll, describe, expect, test } from 'vitest'; import { providePathImplementation } from '../../../../../src/providers/node/path/path'; import { TestPathProvider } from '../../../stubs/providers/node/Node.Path.Provider'; -import { updateEcosystemReactives } from "src/r2mm/ecosystem/EcosystemMerge"; +import { updateEcosystemReactives } from "src/r2mm/ecosystem/EcosystemSchema"; class ProfileProviderImpl extends ProfileProvider { diff --git a/test/vitest/utils/InstallLogicUtils.ts b/test/vitest/utils/InstallLogicUtils.ts index f1807e6e1..9d3dafe87 100644 --- a/test/vitest/utils/InstallLogicUtils.ts +++ b/test/vitest/utils/InstallLogicUtils.ts @@ -19,7 +19,7 @@ import PathResolver from '../../../src/r2mm/manager/PathResolver'; import {providePathImplementation} from "../../../src/providers/node/path/path"; import {TestPathProvider} from "../stubs/providers/node/Node.Path.Provider"; import {expect} from 'vitest'; -import {updateEcosystemReactives} from "../../../src/r2mm/ecosystem/EcosystemMerge"; +import {updateEcosystemReactives} from "../../../src/r2mm/ecosystem/EcosystemSchema"; class ProfileProviderImpl extends ProfileProvider { ensureProfileDirectory(directory: string, profile: string): void { From 380210faf9fe3566e7663176df9021be50668a48 Mon Sep 17 00:00:00 2001 From: cade Date: Thu, 9 Apr 2026 14:25:37 +0100 Subject: [PATCH 10/20] Commented out updateLatestEcosystemSchema temporarily --- src/pages/GameSelectionScreen.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/GameSelectionScreen.vue b/src/pages/GameSelectionScreen.vue index 72890dd7d..a601a74f2 100644 --- a/src/pages/GameSelectionScreen.vue +++ b/src/pages/GameSelectionScreen.vue @@ -369,7 +369,8 @@ onMounted(async () => { await store.dispatch('resetLocalState'); - updateLatestEcosystemSchema(); + // TODO - Enable once updating is viable + // updateLatestEcosystemSchema(); settings.value = await ManagerSettings.getSingleton(GameManager.defaultGame); const globalSettings = settings.value.getContext().global; From 16b23cdd3d0f1a304f433e552f6cff0e6905a12b Mon Sep 17 00:00:00 2001 From: cade Date: Tue, 14 Apr 2026 09:02:21 +0100 Subject: [PATCH 11/20] Added cached ecosystem load failure fallback --- src/r2mm/ecosystem/EcosystemSchema.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/r2mm/ecosystem/EcosystemSchema.ts b/src/r2mm/ecosystem/EcosystemSchema.ts index 6678a9d24..28ddcedda 100644 --- a/src/r2mm/ecosystem/EcosystemSchema.ts +++ b/src/r2mm/ecosystem/EcosystemSchema.ts @@ -11,6 +11,7 @@ import VersionNumber from "../../model/VersionNumber"; import ManagerInformation from "../../_managerinf/ManagerInformation"; import {EcosystemModloaderPackages, EcosystemSupportedGames} from "../../model/schema/ThunderstoreSchema"; import {updateModLoaderExports} from "../installing/profile_installers/ModLoaderVariantRecord"; +import LoggerProvider, {LogSeverity} from "src/providers/ror2/logging/LoggerProvider"; export type VersionedThunderstoreEcosystem = ThunderstoreEcosystem & {version: string}; @@ -70,7 +71,17 @@ async function resolveCachedEcosystemSchema(): Promise Date: Wed, 15 Apr 2026 10:47:28 +0100 Subject: [PATCH 12/20] Made the try/catch around resolveCachedEcosystemSchema safer --- src/r2mm/ecosystem/EcosystemSchema.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/r2mm/ecosystem/EcosystemSchema.ts b/src/r2mm/ecosystem/EcosystemSchema.ts index 28ddcedda..30781f967 100644 --- a/src/r2mm/ecosystem/EcosystemSchema.ts +++ b/src/r2mm/ecosystem/EcosystemSchema.ts @@ -71,9 +71,12 @@ async function resolveCachedEcosystemSchema(): Promise { From d8825c94b9dc70423f85a38117a336d38cc105ed Mon Sep 17 00:00:00 2001 From: cade Date: Wed, 15 Apr 2026 10:52:08 +0100 Subject: [PATCH 13/20] Fixed usage of getGameConfigBySettingsIdentifier after rebase --- src/installers/InstallRulePluginInstaller.ts | 4 ++-- .../tests/unit/Installers/ModLoader/Installer.Tests.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/installers/InstallRulePluginInstaller.ts b/src/installers/InstallRulePluginInstaller.ts index e91fe7745..73b70fe5a 100644 --- a/src/installers/InstallRulePluginInstaller.ts +++ b/src/installers/InstallRulePluginInstaller.ts @@ -12,7 +12,7 @@ import ModFileTracker from "../model/installing/ModFileTracker"; import ConflictManagementProvider from "../providers/generic/installing/ConflictManagementProvider"; import PathResolver from "../r2mm/manager/PathResolver"; import ZipProvider from "../providers/generic/zip/ZipProvider"; -import { EcosystemSchema, TrackingMethod } from "../model/schema/ThunderstoreSchema"; +import { getGameConfigBySettingsIdentifier, TrackingMethod } from "../model/schema/ThunderstoreSchema"; import ModMode from "../model/enums/ModMode"; import GameManager from "../model/game/GameManager"; @@ -385,7 +385,7 @@ export class InstallRulePluginInstaller implements PackageInstaller { // While it's not ideal that this same method is called repeatedly, // the code path below currently takes <1ms to execute so we should be fine. - const gameConfig = EcosystemSchema.getGameConfigBySettingsIdentifier(GameManager.activeGame.settingsIdentifier); + const gameConfig = getGameConfigBySettingsIdentifier(GameManager.activeGame.settingsIdentifier); if (gameConfig === undefined) { throw new Error(`Game config not found for ${GameManager.activeGame.settingsIdentifier}`); } diff --git a/test/vitest/tests/unit/Installers/ModLoader/Installer.Tests.spec.ts b/test/vitest/tests/unit/Installers/ModLoader/Installer.Tests.spec.ts index be35b2508..4be3d8536 100644 --- a/test/vitest/tests/unit/Installers/ModLoader/Installer.Tests.spec.ts +++ b/test/vitest/tests/unit/Installers/ModLoader/Installer.Tests.spec.ts @@ -7,7 +7,7 @@ import GameManager from '../../../../../../src/model/game/GameManager'; import GenericProfileInstaller from '../../../../../../src/r2mm/installing/profile_installers/GenericProfileInstaller'; import InstallationRules from '../../../../../../src/r2mm/installing/InstallationRules'; import { createManifest, installLogicBeforeEach } from '../../../../utils/InstallLogicUtils'; -import { EcosystemSchema, TrackingMethod } from '../../../../../../src/model/schema/ThunderstoreSchema'; +import { getGameConfigBySettingsIdentifier, TrackingMethod } from '../../../../../../src/model/schema/ThunderstoreSchema'; import { describe, beforeEach, test, expect } from 'vitest'; import { providePathImplementation } from '../../../../../../src/providers/node/path/path'; import { TestPathProvider } from '../../../../stubs/providers/node/Node.Path.Provider'; @@ -186,7 +186,7 @@ describe('Installer Tests', () => { ProfileInstallerProvider.provide(() => new GenericProfileInstaller()); await ProfileInstallerProvider.instance.installMod(pkg, Profile.getActiveProfile().asImmutableProfile()); - const config = EcosystemSchema.getGameConfigBySettingsIdentifier(GameManager.activeGame.internalFolderName); + const config = getGameConfigBySettingsIdentifier(GameManager.activeGame.internalFolderName); if (config === undefined) { throw new Error(`Game config not found for ${GameManager.activeGame.internalFolderName}`); } From e31b49bbc2ec0b4e8aed4663e2d61d509c66c3da Mon Sep 17 00:00:00 2001 From: cade Date: Wed, 15 Apr 2026 11:39:16 +0100 Subject: [PATCH 14/20] Added cached content validation and fixed imports --- src/r2mm/ecosystem/EcosystemSchema.ts | 11 ++++++++--- .../unit/EcosystemSchema/EcosystemSchema.spec.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/r2mm/ecosystem/EcosystemSchema.ts b/src/r2mm/ecosystem/EcosystemSchema.ts index 30781f967..add953844 100644 --- a/src/r2mm/ecosystem/EcosystemSchema.ts +++ b/src/r2mm/ecosystem/EcosystemSchema.ts @@ -11,7 +11,7 @@ import VersionNumber from "../../model/VersionNumber"; import ManagerInformation from "../../_managerinf/ManagerInformation"; import {EcosystemModloaderPackages, EcosystemSupportedGames} from "../../model/schema/ThunderstoreSchema"; import {updateModLoaderExports} from "../installing/profile_installers/ModLoaderVariantRecord"; -import LoggerProvider, {LogSeverity} from "src/providers/ror2/logging/LoggerProvider"; +import LoggerProvider, {LogSeverity} from "../../providers/ror2/logging/LoggerProvider"; export type VersionedThunderstoreEcosystem = ThunderstoreEcosystem & {version: string}; @@ -37,10 +37,12 @@ async function writeLatestEcosystemSchema(schema: ThunderstoreEcosystem): Promis async function getLastSavedEcosystemSchema(): Promise { const contentBuffer = await FsProvider.instance.readFile(await getMergedEcosystemPath()); const content = contentBuffer.toString("utf8"); - return JSON.parse(content); + const parsedContent = JSON.parse(content); + await validateSchema(parsedContent); + return parsedContent; } -async function loadBundledSchema(): Promise { +async function validateSchema(schema: any): Promise { const ajv = new Ajv(); addFormats(ajv); @@ -50,7 +52,10 @@ async function loadBundledSchema(): Promise { if (!isOk) { throw new R2Error("Schema validation error", ajv.errorsText(validate.errors)); } +} +async function loadBundledSchema(): Promise { + await validateSchema(bundledEcosystem); return bundledEcosystem as ThunderstoreEcosystem; } diff --git a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts index d479fb911..bcdf0a1fe 100644 --- a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts +++ b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import {beforeEach, describe, expect, test} from 'vitest'; -import {VersionedThunderstoreEcosystem, updateEcosystemReactives, updateLatestEcosystemSchema} from 'src/r2mm/ecosystem/EcosystemSchema'; +import {VersionedThunderstoreEcosystem, updateEcosystemReactives, updateLatestEcosystemSchema} from '../../../../../src/r2mm/ecosystem/EcosystemSchema'; import {EcosystemModloaderPackages, EcosystemSupportedGames} from '../../../../../src/model/schema/ThunderstoreSchema'; import {MODLOADER_PACKAGES, MOD_LOADER_VARIANTS, updateModLoaderExports} from '../../../../../src/r2mm/installing/profile_installers/ModLoaderVariantRecord'; import InMemoryFsProvider from '../../../stubs/providers/InMemory.FsProvider'; From 78e9f6d53ebeb8914a1191ca5c2e3cb3cae89279 Mon Sep 17 00:00:00 2001 From: cade Date: Tue, 21 Apr 2026 09:59:54 +0800 Subject: [PATCH 15/20] Fixed non-relative import --- src/assets/data/ecosystem.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/assets/data/ecosystem.json b/src/assets/data/ecosystem.json index fcb503b5e..4af35522d 100644 --- a/src/assets/data/ecosystem.json +++ b/src/assets/data/ecosystem.json @@ -40322,4 +40322,4 @@ "loader": "bepinex" } ] -} \ No newline at end of file +} From bb9e24adac6380811b4c0b535f9efc821c6f310f Mon Sep 17 00:00:00 2001 From: cade Date: Tue, 21 Apr 2026 12:13:31 +0800 Subject: [PATCH 16/20] Fixed validate schema and added method to init with bundle only --- src/r2mm/ecosystem/EcosystemSchema.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/r2mm/ecosystem/EcosystemSchema.ts b/src/r2mm/ecosystem/EcosystemSchema.ts index add953844..7764f6d87 100644 --- a/src/r2mm/ecosystem/EcosystemSchema.ts +++ b/src/r2mm/ecosystem/EcosystemSchema.ts @@ -47,7 +47,7 @@ async function validateSchema(schema: any): Promise { addFormats(ajv); const validate = ajv.compile(jsonSchema); - const isOk = validate(bundledEcosystem); + const isOk = validate(schema); if (!isOk) { throw new R2Error("Schema validation error", ajv.errorsText(validate.errors)); @@ -109,3 +109,8 @@ export async function updateEcosystemReactives() { const mergedSchema = await resolveCachedEcosystemSchema(); await internalUpdateEcosystemReactives(mergedSchema); } + +export async function useBundledForEcosystemReactives() { + const bundledSchema = await loadBundledSchema(); + await internalUpdateEcosystemReactives(bundledSchema); +} From 2ada8858eae9246fee913c72d7f735908ff7fa0d Mon Sep 17 00:00:00 2001 From: cade Date: Tue, 28 Apr 2026 09:07:43 +0800 Subject: [PATCH 17/20] Fixed EcosystemSchema.spec.ts tests --- .../EcosystemSchema/EcosystemSchema.spec.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts index bcdf0a1fe..201dd2b73 100644 --- a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts +++ b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts @@ -1,6 +1,5 @@ import * as path from 'path'; -import {beforeEach, describe, expect, test} from 'vitest'; - +import {afterEach, beforeEach, describe, expect, MockInstance, test, vi} from 'vitest'; import {VersionedThunderstoreEcosystem, updateEcosystemReactives, updateLatestEcosystemSchema} from '../../../../../src/r2mm/ecosystem/EcosystemSchema'; import {EcosystemModloaderPackages, EcosystemSupportedGames} from '../../../../../src/model/schema/ThunderstoreSchema'; import {MODLOADER_PACKAGES, MOD_LOADER_VARIANTS, updateModLoaderExports} from '../../../../../src/r2mm/installing/profile_installers/ModLoaderVariantRecord'; @@ -10,6 +9,8 @@ import PathResolver from '../../../../../src/r2mm/manager/PathResolver'; import {providePathImplementation} from '../../../../../src/providers/node/path/path'; import {TestPathProvider} from '../../../stubs/providers/node/Node.Path.Provider'; import ManagerInformation from '../../../../../src/_managerinf/ManagerInformation'; +import LoggerProvider from '../../../../../src/providers/ror2/logging/LoggerProvider'; +import StubLoggerProvider from '../../../stubs/providers/stub.LoggerProvider'; const TEST_ROOT = 'TEST_ROOT'; const latestSchemaFilePath = path.join(TEST_ROOT, 'latest-ecosystem-schema.json'); @@ -29,20 +30,35 @@ async function writeCacheFile(schema: Partial) { describe('EcosystemSchema', () => { + let spyLogger: MockInstance; + let mockJsonSchema: any = {}; + beforeEach(async () => { + vi.mock('../../../../../src/assets/data/ecosystemJsonSchema.json', () => ({ + get default() { return mockJsonSchema; } + })); + mockJsonSchema = {}; providePathImplementation(() => TestPathProvider); InMemoryFsProvider.clear(); FsProvider.provide(() => new InMemoryFsProvider()); PathResolver.ROOT = TEST_ROOT; EcosystemSupportedGames.value = []; EcosystemModloaderPackages.value = []; + const loggerImpl = new StubLoggerProvider(); + LoggerProvider.provide(() => loggerImpl); + spyLogger = vi.spyOn(LoggerProvider.instance, 'Log').mockImplementation(() => {}); updateModLoaderExports(); await FsProvider.instance.mkdirs(TEST_ROOT); }); + afterEach(() => { + vi.restoreAllMocks(); + }) + describe('updateEcosystemReactives', () => { test('falls back to bundled schema when no cache file exists', async () => { + expect(EcosystemSupportedGames.value).toHaveLength(0); expect(EcosystemModloaderPackages.value).toHaveLength(0); expect(await FsProvider.instance.exists(latestSchemaFilePath)).toBe(false); @@ -74,6 +90,15 @@ describe('EcosystemSchema', () => { expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); }); + test('falls back to bundled schema and logs error when cached content is invalid', async () => { + await FsProvider.instance.writeFile(latestSchemaFilePath, 'not valid json'); + + await updateEcosystemReactives(); + + expect(EcosystemSupportedGames.value.length).toBeGreaterThan(0); + expect(spyLogger).toHaveBeenCalledOnce(); + }); + test('populates mod loader exports after updating reactives', async () => { expect(MODLOADER_PACKAGES.length).toBe(0); expect(Object.keys(MOD_LOADER_VARIANTS).length).toBe(0); From 12d0ffc7ba2fa4d758cc745149f56a35c097cf7f Mon Sep 17 00:00:00 2001 From: cade Date: Tue, 28 Apr 2026 09:10:17 +0800 Subject: [PATCH 18/20] Added missing StubLoggerProvider from previous commit --- test/vitest/stubs/providers/stub.LoggerProvider.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 test/vitest/stubs/providers/stub.LoggerProvider.ts diff --git a/test/vitest/stubs/providers/stub.LoggerProvider.ts b/test/vitest/stubs/providers/stub.LoggerProvider.ts new file mode 100644 index 000000000..525386d90 --- /dev/null +++ b/test/vitest/stubs/providers/stub.LoggerProvider.ts @@ -0,0 +1,10 @@ +import LoggerProvider, { LogSeverity } from "../../../../src/providers/ror2/logging/LoggerProvider"; + +export default class StubLoggerProvider extends LoggerProvider { + public override Log(severity: LogSeverity, error: string): void { + throw new Error("Method not implemented."); + } + override Write(): void { + throw new Error("Method not implemented."); + } +} From 8cfce9e9816897c03fc6a1c9f205a174c8c81b2c Mon Sep 17 00:00:00 2001 From: cade Date: Tue, 28 Apr 2026 09:12:17 +0800 Subject: [PATCH 19/20] Fixed mockJsonSchema declaration order --- test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts index 201dd2b73..03815bc40 100644 --- a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts +++ b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts @@ -34,10 +34,10 @@ describe('EcosystemSchema', () => { let mockJsonSchema: any = {}; beforeEach(async () => { + mockJsonSchema = {}; vi.mock('../../../../../src/assets/data/ecosystemJsonSchema.json', () => ({ get default() { return mockJsonSchema; } })); - mockJsonSchema = {}; providePathImplementation(() => TestPathProvider); InMemoryFsProvider.clear(); FsProvider.provide(() => new InMemoryFsProvider()); From 6afe9e4fdd9562da23725e3f43c5191aea69fd98 Mon Sep 17 00:00:00 2001 From: cade Date: Tue, 28 Apr 2026 09:18:26 +0800 Subject: [PATCH 20/20] Moved mockJsonSchema to module level to work around CI quirk --- .../tests/unit/EcosystemSchema/EcosystemSchema.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts index 03815bc40..6ffab811f 100644 --- a/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts +++ b/test/vitest/tests/unit/EcosystemSchema/EcosystemSchema.spec.ts @@ -1,5 +1,11 @@ import * as path from 'path'; import {afterEach, beforeEach, describe, expect, MockInstance, test, vi} from 'vitest'; + +let mockJsonSchema: object = {}; +vi.mock('../../../../../src/assets/data/ecosystemJsonSchema.json', () => ({ + get default() { return mockJsonSchema; } +})); + import {VersionedThunderstoreEcosystem, updateEcosystemReactives, updateLatestEcosystemSchema} from '../../../../../src/r2mm/ecosystem/EcosystemSchema'; import {EcosystemModloaderPackages, EcosystemSupportedGames} from '../../../../../src/model/schema/ThunderstoreSchema'; import {MODLOADER_PACKAGES, MOD_LOADER_VARIANTS, updateModLoaderExports} from '../../../../../src/r2mm/installing/profile_installers/ModLoaderVariantRecord'; @@ -31,13 +37,9 @@ async function writeCacheFile(schema: Partial) { describe('EcosystemSchema', () => { let spyLogger: MockInstance; - let mockJsonSchema: any = {}; beforeEach(async () => { mockJsonSchema = {}; - vi.mock('../../../../../src/assets/data/ecosystemJsonSchema.json', () => ({ - get default() { return mockJsonSchema; } - })); providePathImplementation(() => TestPathProvider); InMemoryFsProvider.clear(); FsProvider.provide(() => new InMemoryFsProvider());