import type { Archon, LauncherMeta } from '@modrinth/api-client' import { useQueryClient } from '@tanstack/vue-query' import { computed, type ComputedRef, type Ref, ref, type ShallowRef, watch } from 'vue' import type { ComponentExposed } from 'vue-component-type-helpers' import { useDebugLogger } from '#ui/composables/debug-logger' import { defineMessages, type MessageDescriptor, useVIntl, type VIntlFormatters, } from '#ui/composables/i18n' import { formatLoaderLabel } from '#ui/utils/loaders' import { createContext, injectModrinthClient } from '../../../providers' import type { ImportableLauncher } from '../../../providers/instance-import' import type { MultiStageModal, StageConfigInput } from '../../base' import type { ComboboxOption } from '../../base/Combobox.vue' import { stageConfigs } from './stages' export type FlowType = 'world' | 'server-onboarding' | 'reset-server' | 'instance' export type SetupType = 'modpack' | 'custom' | 'vanilla' export type Gamemode = 'survival' | 'creative' | 'hardcore' export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard' export type LoaderVersionType = 'stable' | 'latest' | 'other' export type GeneratorSettingsMode = 'default' | 'flat' | 'custom' export type LoaderManifestResolver = (loader: string) => Promise export interface LoaderVersionEntry { id: string stable: boolean } const loaderManifestQueryKey = (loader: string) => ['creation-flow', 'loader-manifest', loader] as const const paperSupportedVersionsQueryKey = ['creation-flow', 'paper', 'supported-versions'] as const const purpurSupportedVersionsQueryKey = ['creation-flow', 'purpur', 'supported-versions'] as const export const creationFlowMessages = defineMessages({ createWorldTitle: { id: 'creation-flow.title.create-world', defaultMessage: 'Create instance', }, setUpServerTitle: { id: 'creation-flow.title.set-up-server', defaultMessage: 'Create instance', }, resetServerTitle: { id: 'creation-flow.title.reset-server', defaultMessage: 'Reset instance', }, createInstanceTitle: { id: 'creation-flow.title.create-instance', defaultMessage: 'Create instance', }, createWorldButton: { id: 'creation-flow.button.create-world', defaultMessage: 'Create instance', }, createInstanceButton: { id: 'creation-flow.button.create-instance', defaultMessage: 'Create instance', }, setupServerButton: { id: 'creation-flow.button.setup-server', defaultMessage: 'Create instance', }, finishButton: { id: 'creation-flow.button.finish', defaultMessage: 'Finish', }, importInstanceTitle: { id: 'creation-flow.title.import-instance', defaultMessage: 'Import instance', }, importButton: { id: 'creation-flow.button.import', defaultMessage: 'Import', }, importInstancesButton: { id: 'creation-flow.button.import-instances', defaultMessage: 'Import {count, plural, one {# instance} other {# instances}}', }, chooseModpackTitle: { id: 'creation-flow.title.choose-modpack', defaultMessage: 'Choose modpack', }, }) export const flowTypeHeadingMessages: Record = { world: creationFlowMessages.createWorldTitle, 'server-onboarding': creationFlowMessages.createWorldTitle, 'reset-server': creationFlowMessages.resetServerTitle, instance: creationFlowMessages.createInstanceTitle, } export interface ModpackSelection { projectId: string versionId: string name: string iconUrl?: string } export interface ModpackSearchHit { title: string iconUrl?: string latestVersion?: string } export interface ModpackSearchResult { hits: { project_id: string title: string icon_url: string latest_version?: string }[] total_hits: number offset: number limit: number } export interface CreationFlowContextValue { // Flow flowType: FlowType formatMessage: VIntlFormatters['formatMessage'] // Configuration availableLoaders: string[] showSnapshotToggle: boolean disableClose: boolean isInitialSetup: boolean // Initial values initialLoader: string | null initialGameVersion: string | null // State setupType: Ref isImportMode: Ref worldName: Ref gamemode: Ref difficulty: Ref worldSeed: Ref worldTypeOption: Ref generateStructures: Ref generatorSettingsMode: Ref generatorSettingsCustom: Ref // Instance-specific state instanceName: Ref autoInstanceName: ComputedRef instanceIcon: Ref instanceIconUrl: Ref instanceIconPath: Ref // Loader/version state (custom setup) selectedLoader: Ref selectedGameVersion: Ref loaderVersionType: Ref selectedLoaderVersion: Ref hideLoaderChips: ComputedRef hideLoaderVersion: ComputedRef showSnapshots: Ref loaderVersionsCache: Ref> paperSupportedVersions: Ref | null> purpurSupportedVersions: Ref | null> // Modpack state modpackSelection: Ref modpackFile: Ref modpackFilePath: Ref // Modpack search state (persisted across stage navigation) modpackSearchProjectId: Ref modpackSearchVersionId: Ref modpackSearchOptions: Ref[]> modpackVersionOptions: Ref[]> modpackSearchHits: Ref> // Import state (instance flow only) importLaunchers: Ref importSelectedInstances: Ref>> importSearchQuery: Ref // Confirm stage hardReset: Ref // Loading state (set when finish() is called, cleared on reset) loading: Ref finishDisabled: ComputedRef finishDisabledTooltip: ComputedRef // Backup state (set by InlineBackupCreator in reset-server flow) isBackingUp: Ref cancelBackup: Ref<(() => void) | null> // Modal modal: ShallowRef | null> stageConfigs: StageConfigInput[] // Callbacks onBack: (() => void) | null // Methods reset: (instanceCount?: number) => Promise setSetupType: (type: SetupType) => void setImportMode: () => void browseModpacks: () => void finish: () => void buildProperties: () => Archon.Content.v1.PropertiesFields fetchLoaderMetadata: (loader?: string | null) => Promise prefetchLoaderMetadata: () => Promise // Platform-provided search searchModpacks: (query: string, limit?: number) => Promise getProjectVersions: (projectId: string) => Promise<{ id: string }[]> getLoaderManifest: LoaderManifestResolver | null } export const [injectCreationFlowContext, provideCreationFlowContext] = createContext('CreationFlowModal') export interface CreationFlowOptions { availableLoaders?: string[] showSnapshotToggle?: boolean disableClose?: boolean isInitialSetup?: boolean initialLoader?: string initialGameVersion?: string fetchExistingInstanceNames?: () => Promise onBack?: () => void searchModpacks?: (query: string, limit?: number) => Promise getProjectVersions?: (projectId: string) => Promise<{ id: string }[]> getLoaderManifest?: LoaderManifestResolver finishDisabled?: ComputedRef finishDisabledTooltip?: ComputedRef } export function createCreationFlowContext( modal: ShallowRef | null>, flowType: FlowType, emit: { browseModpacks: () => void create: (config: CreationFlowContextValue) => void }, options: CreationFlowOptions = {}, ): CreationFlowContextValue { const debug = useDebugLogger('CreationFlow') const client = injectModrinthClient() const queryClient = useQueryClient() const { formatMessage } = useVIntl() const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt'] const showSnapshotToggle = options.showSnapshotToggle ?? false const disableClose = options.disableClose ?? false const isInitialSetup = options.isInitialSetup ?? false const initialLoader = options.initialLoader ?? null const initialGameVersion = options.initialGameVersion ?? null const onBack = options.onBack ?? null const searchModpacks = options.searchModpacks! const getProjectVersions = options.getProjectVersions! const getLoaderManifest = options.getLoaderManifest ?? null const finishDisabled = options.finishDisabled ?? computed(() => false) const finishDisabledTooltip = options.finishDisabledTooltip ?? computed(() => undefined) const setupType = ref(null) const isImportMode = ref(false) const worldName = ref('') const gamemode = ref('survival') const difficulty = ref('normal') const worldSeed = ref('') const worldTypeOption = ref('minecraft:normal') const generateStructures = ref(true) const generatorSettingsMode = ref('default') const generatorSettingsCustom = ref('') // Instance-specific state const instanceName = ref('') const existingInstanceNames = ref([]) const fetchExistingInstanceNames = options.fetchExistingInstanceNames ?? null const instanceIcon = ref(null) const instanceIconUrl = ref(null) const instanceIconPath = ref(null) // Revoke old object URL when icon is cleared to avoid memory leaks watch(instanceIconUrl, (_newUrl, oldUrl) => { if (oldUrl && oldUrl.startsWith('blob:')) { URL.revokeObjectURL(oldUrl) } }) const selectedLoader = ref(null) const selectedGameVersion = ref(null) const loaderVersionType = ref('stable') const selectedLoaderVersion = ref(null) const showSnapshots = ref(false) const loaderVersionsCache = ref>( {}, ) const paperSupportedVersions = ref | null>(null) const purpurSupportedVersions = ref | null>(null) const autoInstanceName = computed(() => { const loader = selectedLoader.value const version = selectedGameVersion.value if (!version) return '' const loaderName = loader ? formatLoaderLabel(loader) : 'Vanilla' const baseName = `${loaderName} ${version}` const names = new Set(existingInstanceNames.value) if (!names.has(baseName)) return baseName let counter = 1 while (names.has(`${baseName} (${counter})`)) { counter++ } return `${baseName} (${counter})` }) const modpackSelection = ref(null) const modpackFile = ref(null) const modpackFilePath = ref(null) // Modpack search state (persisted across stage navigation) const modpackSearchProjectId = ref() const modpackSearchVersionId = ref() const modpackSearchOptions = ref[]>([]) const modpackVersionOptions = ref[]>([]) const modpackSearchHits = ref>({}) // Import state (instance flow only) const importLaunchers = ref([]) const importSelectedInstances = ref>>({}) const importSearchQuery = ref('') const hardReset = ref(isInitialSetup) const loading = ref(false) const isBackingUp = ref(false) const cancelBackup = ref<(() => void) | null>(null) // hideLoaderChips: hides the entire loader chips section (only for vanilla world type in world/server flows) const hideLoaderChips = computed(() => setupType.value === 'vanilla') // hideLoaderVersion: hides the loader version section (vanilla world type OR vanilla selected as loader chip) const hideLoaderVersion = computed( () => setupType.value === 'vanilla' || selectedLoader.value === 'vanilla', ) function toApiLoaderName(loader: string): string { return loader === 'neoforge' ? 'neo' : loader } async function fetchLoaderManifest(loader: string) { const apiLoader = toApiLoaderName(loader) if (loaderVersionsCache.value[apiLoader]) return try { const data = await queryClient.fetchQuery({ queryKey: loaderManifestQueryKey(apiLoader), queryFn: async () => (await getLoaderManifest?.(apiLoader)) ?? (await client.launchermeta.manifest_v0.getManifest(apiLoader)), staleTime: Infinity, }) loaderVersionsCache.value[apiLoader] = data.gameVersions debug('fetchLoaderManifest: loaded', apiLoader, 'gameVersions:', data.gameVersions.length) } catch (error) { debug('fetchLoaderManifest: failed', apiLoader, error) loaderVersionsCache.value[apiLoader] = [] } } async function fetchPaperSupportedVersions() { if (paperSupportedVersions.value) return try { paperSupportedVersions.value = await queryClient.fetchQuery({ queryKey: paperSupportedVersionsQueryKey, queryFn: async () => { const project = await client.paper.versions_v3.getProject() return new Set(Object.values(project.versions).flat()) }, staleTime: Infinity, }) } catch { paperSupportedVersions.value = new Set() } } async function fetchPurpurSupportedVersions() { if (purpurSupportedVersions.value) return try { purpurSupportedVersions.value = await queryClient.fetchQuery({ queryKey: purpurSupportedVersionsQueryKey, queryFn: async () => { const project = await client.purpur.versions_v2.getProject() return new Set(project.versions) }, staleTime: Infinity, }) } catch { purpurSupportedVersions.value = new Set() } } async function fetchLoaderMetadata(loader?: string | null) { if (!loader || loader === 'vanilla') return if (loader === 'paper') { await fetchPaperSupportedVersions() return } if (loader === 'purpur') { await fetchPurpurSupportedVersions() return } await fetchLoaderManifest(loader) } async function prefetchLoaderMetadata() { await Promise.allSettled( availableLoaders .filter((loader) => loader !== 'vanilla') .map((loader) => fetchLoaderMetadata(loader)), ) } async function reset() { if (fetchExistingInstanceNames) { existingInstanceNames.value = await fetchExistingInstanceNames() } setupType.value = null isImportMode.value = false worldName.value = flowType === 'world' ? 'My instance' : '' gamemode.value = 'survival' difficulty.value = 'normal' worldSeed.value = '' worldTypeOption.value = 'minecraft:normal' generateStructures.value = true generatorSettingsMode.value = 'default' generatorSettingsCustom.value = '' // Instance-specific instanceName.value = '' instanceIconUrl.value = null instanceIcon.value = null instanceIconPath.value = null selectedLoader.value = null selectedGameVersion.value = null loaderVersionType.value = 'stable' selectedLoaderVersion.value = null showSnapshots.value = false modpackSelection.value = null modpackFile.value = null modpackFilePath.value = null modpackSearchProjectId.value = undefined modpackSearchVersionId.value = undefined modpackSearchOptions.value = [] modpackVersionOptions.value = [] modpackSearchHits.value = {} // Import state importLaunchers.value = [] importSelectedInstances.value = {} importSearchQuery.value = '' hardReset.value = isInitialSetup loading.value = false isBackingUp.value = false cancelBackup.value = null } function setSetupType(type: SetupType) { debug('setSetupType:', type) isImportMode.value = false setupType.value = type if (type === 'modpack') { selectedLoader.value = null selectedLoaderVersion.value = null loaderVersionType.value = 'stable' modal.value?.setStage('modpack') } else { modpackSelection.value = null modpackFile.value = null modpackFilePath.value = null if (type === 'vanilla') { selectedLoader.value = null selectedLoaderVersion.value = null loaderVersionType.value = 'stable' } // both custom and vanilla go to custom-setup // vanilla just hides loader chips via hideLoaderChips computed modal.value?.setStage('custom-setup') } } function setImportMode() { isImportMode.value = true setupType.value = null modal.value?.setStage('import-instance') } function browseModpacks() { modal.value?.hide() emit.browseModpacks() } function finish() { if (finishDisabled.value) return debug('finish() called, state:', { setupType: setupType.value, selectedLoader: selectedLoader.value, selectedGameVersion: selectedGameVersion.value, selectedLoaderVersion: selectedLoaderVersion.value, modpackSelection: modpackSelection.value, hasModpackFile: !!modpackFile.value, }) loading.value = true emit.create(contextValue) } function buildProperties(): Archon.Content.v1.PropertiesFields { const isHardcore = gamemode.value === 'hardcore' const known: Archon.Content.v1.KnownPropertiesFields = { gamemode: isHardcore ? 'survival' : gamemode.value, hardcore: isHardcore ? 'true' : 'false', difficulty: difficulty.value, level_seed: worldSeed.value || null, level_type: worldTypeOption.value, generate_structures: String(generateStructures.value), } if (generatorSettingsMode.value === 'flat') { known.generator_settings = '' } else if (generatorSettingsMode.value === 'custom' && generatorSettingsCustom.value) { known.generator_settings = generatorSettingsCustom.value } return { known } } const resolvedStageConfigs = disableClose ? stageConfigs.map((stage) => ({ ...stage, disableClose: true })) : stageConfigs const contextValue: CreationFlowContextValue = { flowType, formatMessage, availableLoaders, showSnapshotToggle, disableClose, isInitialSetup, initialLoader, initialGameVersion, setupType, isImportMode, worldName, gamemode, difficulty, worldSeed, worldTypeOption, generateStructures, generatorSettingsMode, generatorSettingsCustom, instanceName, autoInstanceName, instanceIcon, instanceIconUrl, instanceIconPath, selectedLoader, selectedGameVersion, loaderVersionType, selectedLoaderVersion, hideLoaderChips, hideLoaderVersion, showSnapshots, loaderVersionsCache, paperSupportedVersions, purpurSupportedVersions, modpackSelection, modpackFile, modpackFilePath, modpackSearchProjectId, modpackSearchVersionId, modpackSearchOptions, modpackVersionOptions, modpackSearchHits, importLaunchers, importSelectedInstances, importSearchQuery, hardReset, loading, finishDisabled, finishDisabledTooltip, isBackingUp, cancelBackup, modal, stageConfigs: resolvedStageConfigs, onBack, reset, setSetupType, setImportMode, browseModpacks, finish, buildProperties, fetchLoaderMetadata, prefetchLoaderMetadata, searchModpacks, getProjectVersions, getLoaderManifest, } return contextValue }