use std::collections::HashMap;
use anyhow::{bail, ensure, Context};
use mcvm_core::io::java::args::MemoryNum;
use mcvm_core::io::java::install::JavaInstallationKind;
use mcvm_core::util::versions::MinecraftVersionDeser;
use mcvm_plugin::hooks::ModifyInstanceConfig;
use mcvm_shared::id::{InstanceID, ProfileID};
use mcvm_shared::modifications::{ClientType, Modloader, ServerType};
use mcvm_shared::output::MCVMOutput;
use mcvm_shared::pkg::PackageStability;
use mcvm_shared::util::{merge_options, DefaultExt, DeserListOrSingle};
use mcvm_shared::versions::VersionPattern;
use mcvm_shared::Side;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::instance::launch::{LaunchOptions, WrapperCommand};
use crate::instance::{InstKind, Instance, InstanceStoredConfig};
use crate::io::paths::Paths;
use super::package::{PackageConfig, PackageConfigDeser, PackageConfigSource};
use super::profile::{GameModifications, ProfileConfig};
use crate::plugin::PluginManager;
#[derive(Deserialize, Serialize, Clone, Debug)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct InstanceConfig {
#[serde(rename = "type")]
pub side: Option<Side>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(flatten)]
pub common: CommonInstanceConfig,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub window: ClientWindowConfig,
}
#[derive(Deserialize, Serialize, Clone, Default, Debug)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(default)]
pub struct CommonInstanceConfig {
#[serde(skip_serializing_if = "DeserListOrSingle::is_empty")]
pub from: DeserListOrSingle<String>,
pub version: Option<MinecraftVersionDeser>,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub modloader: Option<Modloader>,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub client_type: Option<ClientType>,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub server_type: Option<ServerType>,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub game_modification_version: Option<VersionPattern>,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub package_stability: Option<PackageStability>,
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub launch: LaunchConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub datapack_folder: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<PackageConfigDeser>,
#[serde(flatten)]
#[serde(skip_serializing_if = "serde_json::Map::is_empty")]
pub plugin_config: serde_json::Map<String, serde_json::Value>,
}
impl CommonInstanceConfig {
pub fn merge(&mut self, other: Self) -> &mut Self {
self.from.merge(other.from);
self.version = other.version.or(self.version.clone());
self.modloader = other.modloader.or(self.modloader.clone());
self.client_type = other.client_type.or(self.client_type.clone());
self.server_type = other.server_type.or(self.server_type.clone());
self.package_stability = other.package_stability.or(self.package_stability);
self.launch.merge(other.launch);
self.datapack_folder = other.datapack_folder.or(self.datapack_folder.clone());
self.packages.extend(other.packages);
mcvm_core::util::json::merge_objects(&mut self.plugin_config, other.plugin_config);
self
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(untagged)]
pub enum Args {
List(Vec<String>),
String(String),
}
impl Args {
pub fn parse(&self) -> Vec<String> {
match self {
Self::List(vec) => vec.clone(),
Self::String(string) => string.split(' ').map(|string| string.to_string()).collect(),
}
}
pub fn merge(&mut self, other: Self) {
let mut out = self.parse();
out.extend(other.parse());
*self = Self::List(out);
}
}
impl Default for Args {
fn default() -> Self {
Self::List(Vec::new())
}
}
#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct LaunchArgs {
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub jvm: Args,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub game: Args,
}
#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(untagged)]
pub enum LaunchMemory {
#[default]
None,
Single(String),
Both {
min: String,
max: String,
},
}
fn default_java() -> String {
"auto".into()
}
fn default_flags_preset() -> String {
"none".into()
}
#[derive(Deserialize, Serialize, Debug, PartialEq, Default, Clone)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "type")]
#[serde(rename_all = "snake_case")]
pub enum QuickPlay {
World {
world: String,
},
Server {
server: String,
port: Option<u16>,
},
Realm {
realm: String,
},
#[default]
None,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct LaunchConfig {
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub args: LaunchArgs,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub memory: LaunchMemory,
#[serde(default = "default_java")]
pub java: String,
#[serde(default = "default_flags_preset")]
pub preset: String,
#[serde(default)]
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub wrapper: Option<WrapperCommand>,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub quick_play: QuickPlay,
#[serde(default)]
#[serde(skip_serializing_if = "DefaultExt::is_default")]
pub use_log4j_config: bool,
}
impl LaunchConfig {
pub fn to_options(self) -> anyhow::Result<LaunchOptions> {
let min_mem = match &self.memory {
LaunchMemory::None => None,
LaunchMemory::Single(string) => MemoryNum::parse(string),
LaunchMemory::Both { min, .. } => MemoryNum::parse(min),
};
let max_mem = match &self.memory {
LaunchMemory::None => None,
LaunchMemory::Single(string) => MemoryNum::parse(string),
LaunchMemory::Both { max, .. } => MemoryNum::parse(max),
};
if let Some(min_mem) = &min_mem {
if let Some(max_mem) = &max_mem {
ensure!(
min_mem.to_bytes() <= max_mem.to_bytes(),
"Minimum memory must be less than or equal to maximum memory"
);
}
}
Ok(LaunchOptions {
jvm_args: self.args.jvm.parse(),
game_args: self.args.game.parse(),
min_mem,
max_mem,
java: JavaInstallationKind::parse(&self.java),
env: self.env,
wrapper: self.wrapper,
quick_play: self.quick_play,
use_log4j_config: self.use_log4j_config,
})
}
pub fn merge(&mut self, other: Self) -> &mut Self {
self.args.jvm.merge(other.args.jvm);
self.args.game.merge(other.args.game);
if !matches!(other.memory, LaunchMemory::None) {
self.memory = other.memory;
}
self.java = other.java;
if other.preset != "none" {
self.preset = other.preset;
}
self.env.extend(other.env);
if other.wrapper.is_some() {
self.wrapper = other.wrapper;
}
if !matches!(other.quick_play, QuickPlay::None) {
self.quick_play = other.quick_play;
}
self
}
}
impl Default for LaunchConfig {
fn default() -> Self {
Self {
args: LaunchArgs {
jvm: Args::default(),
game: Args::default(),
},
memory: LaunchMemory::default(),
java: default_java(),
preset: default_flags_preset(),
env: HashMap::new(),
wrapper: None,
quick_play: QuickPlay::default(),
use_log4j_config: false,
}
}
}
#[derive(Deserialize, Serialize, Clone, Debug, Copy, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct WindowResolution {
pub width: u32,
pub height: u32,
}
#[derive(Deserialize, Serialize, Default, Clone, Debug, PartialEq)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(default)]
pub struct ClientWindowConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<WindowResolution>,
}
impl ClientWindowConfig {
pub fn merge(&mut self, other: Self) -> &mut Self {
self.resolution = merge_options(self.resolution, other.resolution);
self
}
}
pub fn merge_instance_configs(preset: &InstanceConfig, config: InstanceConfig) -> InstanceConfig {
let mut out = preset.clone();
out.common.merge(config.common);
out.name = config.name.or(out.name);
out.side = config.side.or(out.side);
out.window.merge(config.window);
out
}
pub fn read_instance_config(
id: InstanceID,
mut config: InstanceConfig,
profiles: &HashMap<ProfileID, ProfileConfig>,
plugins: &PluginManager,
paths: &Paths,
o: &mut impl MCVMOutput,
) -> anyhow::Result<Instance> {
if !is_valid_instance_id(&id) {
bail!("Invalid instance ID '{}'", id.to_string());
}
let profiles: anyhow::Result<Vec<_>> = config
.common
.from
.iter()
.map(|x| {
profiles
.get(&ProfileID::from(x.clone()))
.with_context(|| format!("Derived profile '{x}' does not exist"))
})
.collect();
let profiles = profiles?;
let original_config = config.clone();
for profile in &profiles {
config = merge_instance_configs(&profile.instance, config);
}
let original_config_with_profiles = config.clone();
let side = config.side.context("Instance type was not specified")?;
let packages = consolidate_package_configs(profiles, &config, side);
let kind = match side {
Side::Client => InstKind::client(config.window),
Side::Server => InstKind::server(),
};
let game_modifications = GameModifications::new(
config.common.modloader.clone().unwrap_or_default(),
config.common.client_type.clone().unwrap_or_default(),
config.common.server_type.clone().unwrap_or_default(),
);
let version = config
.common
.version
.clone()
.context("Instance is missing a Minecraft version")?
.to_mc_version();
let results = plugins
.call_hook(ModifyInstanceConfig, &config.common.plugin_config, paths, o)
.context("Failed to apply plugin instance modifications")?;
for result in results {
let result = result.result(o)?;
config
.common
.launch
.args
.jvm
.merge(Args::List(result.additional_jvm_args));
}
let stored_config = InstanceStoredConfig {
name: config.name,
icon: config.icon,
version,
modifications: game_modifications,
modification_version: config.common.game_modification_version,
launch: config.common.launch.to_options()?,
datapack_folder: config.common.datapack_folder,
packages,
package_stability: config.common.package_stability.unwrap_or_default(),
original_config,
original_config_with_profiles,
plugin_config: config.common.plugin_config,
};
let instance = Instance::new(kind, id, stored_config);
Ok(instance)
}
pub fn is_valid_instance_id(id: &str) -> bool {
for c in id.chars() {
if !c.is_ascii() {
return false;
}
if c.is_ascii_punctuation() {
match c {
'_' | '-' | '.' | ':' => {}
_ => return false,
}
}
if c.is_ascii_whitespace() {
return false;
}
}
true
}
fn consolidate_package_configs(
profiles: Vec<&ProfileConfig>,
instance: &InstanceConfig,
side: Side,
) -> Vec<PackageConfig> {
let stability = instance.common.package_stability.unwrap_or_default();
let mut map = HashMap::new();
for profile in profiles {
for pkg in profile.packages.iter_global() {
let pkg = pkg
.clone()
.to_package_config(stability, PackageConfigSource::Profile);
map.insert(pkg.id.clone(), pkg);
}
for pkg in profile.packages.iter_side(side) {
let pkg = pkg
.clone()
.to_package_config(stability, PackageConfigSource::Profile);
map.insert(pkg.id.clone(), pkg);
}
}
for pkg in &instance.common.packages {
let pkg = pkg
.clone()
.to_package_config(stability, PackageConfigSource::Instance);
map.insert(pkg.id.clone(), pkg);
}
let mut out = Vec::new();
for pkg in map.values() {
out.push(pkg.clone());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quickplay_deser() {
#[derive(Deserialize)]
struct Test {
quick_play: QuickPlay,
}
let test = serde_json::from_str::<Test>(
r#"{
"quick_play": {
"type": "server",
"server": "localhost",
"port": 25565,
"world": "test",
"realm": "my_realm"
}
}"#,
)
.unwrap();
assert_eq!(
test.quick_play,
QuickPlay::Server {
server: "localhost".into(),
port: Some(25565)
}
);
}
}