Implement mod dependencies #63

Merged
lucas merged 2 commits from feat/dependencies into master 2023-03-09 14:13:58 +01:00
7 changed files with 164 additions and 38 deletions

View file

@ -10,6 +10,8 @@
- dtmm: check for Steam game update before deployment - dtmm: check for Steam game update before deployment
- dtmm: remove unused bundles from previous deployment - dtmm: remove unused bundles from previous deployment
- dtmm: show dialog for critical errors - dtmm: show dialog for critical errors
- dtmm: check mod order before deployment
- dtmt: add mod dependencies to config
=== Fixed === Fixed

View file

@ -14,7 +14,7 @@ use tokio_stream::wrappers::ReadDirStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use zip::ZipArchive; use zip::ZipArchive;
use crate::state::{ActionState, ModInfo, PackageInfo}; use crate::state::{ActionState, ModInfo, ModOrder, PackageInfo};
use crate::util::config::{ConfigSerialize, LoadOrderEntry}; use crate::util::config::{ConfigSerialize, LoadOrderEntry};
use super::read_sjson_file; use super::read_sjson_file;
@ -216,3 +216,64 @@ where
Ok::<_, color_eyre::Report>(mods) Ok::<_, color_eyre::Report>(mods)
}) })
} }
pub(crate) fn check_mod_order(state: &ActionState) -> Result<()> {
{
let first = state.mods.get(0);
if first.is_none() || !(first.unwrap().id == "dml" && first.unwrap().enabled) {
// TODO: Add a suggestion where to get it, once that's published
eyre::bail!("'Darktide Mod Loader' needs to be installed, enabled and at the top of the load order");
}
}
state
.mods
.iter()
.filter(|i| i.enabled)
.enumerate()
.for_each(|(i, info)| tracing::debug!(i, ?info));
for (i, mod_info) in state.mods.iter().filter(|i| i.enabled).enumerate() {
for dep in &mod_info.depends {
let dep_info = state.mods.iter().enumerate().find(|(_, m)| m.id == dep.id);
match dep_info {
Some((_, dep_info)) if !dep_info.enabled => {
eyre::bail!(
"Dependency '{}' ({}) must be enabled.",
dep_info.name,
dep.id
);
}
Some((j, dep_info)) if dep.order == ModOrder::Before && j >= i => {
eyre::bail!(
"Dependency '{}' ({}) must be loaded before '{}'",
dep_info.name,
dep.id,
mod_info.name
);
}
Some((j, dep_info)) if dep.order == ModOrder::After && j <= i => {
eyre::bail!(
"Dependency '{}' ({}) must be loaded after '{}'",
dep_info.name,
dep.id,
mod_info.name
);
}
None => {
eyre::bail!(
"Missing dependency '{}' for mod '{}'",
dep.id,
mod_info.name
);
}
Some(_) => {
// All good
}
}
}
}
Ok(())
}

View file

@ -21,6 +21,7 @@ use tokio::io::AsyncWriteExt;
use tracing::Instrument; use tracing::Instrument;
use super::read_sjson_file; use super::read_sjson_file;
use crate::controller::app::check_mod_order;
use crate::state::{ActionState, PackageInfo}; use crate::state::{ActionState, PackageInfo};
const MOD_BUNDLE_NAME: &str = "packages/mods"; const MOD_BUNDLE_NAME: &str = "packages/mods";
@ -525,15 +526,14 @@ where
pub(crate) async fn deploy_mods(state: ActionState) -> Result<()> { pub(crate) async fn deploy_mods(state: ActionState) -> Result<()> {
let state = Arc::new(state); let state = Arc::new(state);
{ let (_, game_info, deployment_info) = tokio::try_join!(
let first = state.mods.get(0); async {
if first.is_none() || !(first.unwrap().id == "dml" && first.unwrap().enabled) { let path = state.game_dir.join("bundle");
// TODO: Add a suggestion where to get it, once that's published fs::metadata(&path)
eyre::bail!("'Darktide Mod Loader' needs to be installed, enabled and at the top of the load order"); .await
} .wrap_err("Failed to open game bundle directory")
} .with_suggestion(|| "Double-check 'Game Directory' in the Settings tab.")
},
let (game_info, deployment_info) = tokio::try_join!(
async { async {
tokio::task::spawn_blocking(dtmt_shared::collect_game_info) tokio::task::spawn_blocking(dtmt_shared::collect_game_info)
.await .await
@ -557,10 +557,9 @@ pub(crate) async fn deploy_mods(state: ActionState) -> Result<()> {
) )
.wrap_err("Failed to gather deployment information")?; .wrap_err("Failed to gather deployment information")?;
let game_info = game_info.wrap_err("Failed to collect Steam info")?;
tracing::debug!(?game_info, ?deployment_info); tracing::debug!(?game_info, ?deployment_info);
if let Some(game_info) = game_info {
if deployment_info if deployment_info
.as_ref() .as_ref()
.map(|i| game_info.last_updated > i.timestamp) .map(|i| game_info.last_updated > i.timestamp)
@ -568,6 +567,9 @@ pub(crate) async fn deploy_mods(state: ActionState) -> Result<()> {
{ {
eyre::bail!("Game was updated since last mod deployment. Please reset first."); eyre::bail!("Game was updated since last mod deployment. Please reset first.");
} }
}
check_mod_order(&state)?;
tracing::info!( tracing::info!(
"Deploying {} mods to {}", "Deploying {} mods to {}",

View file

@ -8,13 +8,17 @@ use std::sync::Arc;
use clap::command; use clap::command;
use clap::value_parser; use clap::value_parser;
use clap::Arg; use clap::Arg;
use color_eyre::eyre;
use color_eyre::eyre::Context; use color_eyre::eyre::Context;
use color_eyre::{Report, Result}; use color_eyre::{Report, Result};
use druid::AppLauncher; use druid::AppLauncher;
use druid::SingleUse;
use druid::Target;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::controller::app::load_mods; use crate::controller::app::load_mods;
use crate::controller::worker::work_thread; use crate::controller::worker::work_thread;
use crate::state::ACTION_SHOW_ERROR_DIALOG;
use crate::state::{Delegate, State}; use crate::state::{Delegate, State};
mod controller; mod controller;
@ -57,17 +61,32 @@ fn main() -> Result<()> {
oodle_sys::init(matches.get_one::<String>("oodle")); oodle_sys::init(matches.get_one::<String>("oodle"));
} }
let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel();
let delegate = Delegate::new(action_tx);
let launcher = AppLauncher::with_window(ui::window::main::new()).delegate(delegate);
let event_sink = launcher.get_external_handle();
let config = util::config::read_config(&default_config_path, &matches) let config = util::config::read_config(&default_config_path, &matches)
.wrap_err("Failed to read config file")?; .wrap_err("Failed to read config file")?;
let game_info = dtmt_shared::collect_game_info();
let game_info = dtmt_shared::collect_game_info()?;
tracing::debug!(?config, ?game_info); tracing::debug!(?config, ?game_info);
let game_dir = config.game_dir.or_else(|| game_info.map(|i| i.path));
if game_dir.is_none() {
let err =
eyre::eyre!("No Game Directory set. Head to the 'Settings' tab to set it manually",);
event_sink
.submit_command(ACTION_SHOW_ERROR_DIALOG, SingleUse::new(err), Target::Auto)
.expect("failed to send command");
}
let initial_state = { let initial_state = {
let mut state = State::new( let mut state = State::new(
config.path, config.path,
config.game_dir.unwrap_or(game_info.path), game_dir.unwrap_or_default(),
config.data_dir.unwrap_or_default(), config.data_dir.unwrap_or_default(),
); );
state.mods = load_mods(state.get_mod_dir(), config.mod_order.iter()) state.mods = load_mods(state.get_mod_dir(), config.mod_order.iter())
@ -75,12 +94,6 @@ fn main() -> Result<()> {
state state
}; };
let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel();
let delegate = Delegate::new(action_tx);
let launcher = AppLauncher::with_window(ui::window::main::new()).delegate(delegate);
let event_sink = launcher.get_external_handle();
std::thread::spawn(move || { std::thread::spawn(move || {
let event_sink = Arc::new(RwLock::new(event_sink)); let event_sink = Arc::new(RwLock::new(event_sink));
let action_rx = Arc::new(RwLock::new(action_rx)); let action_rx = Arc::new(RwLock::new(action_rx));

View file

@ -39,6 +39,36 @@ pub(crate) struct ModResourceInfo {
pub localization: Option<PathBuf>, pub localization: Option<PathBuf>,
} }
#[derive(Clone, Data, Debug, PartialEq)]
pub(crate) enum ModOrder {
Before,
After,
}
#[derive(Clone, Data, Debug, PartialEq)]
pub(crate) struct ModDependency {
pub id: String,
pub order: ModOrder,
}
impl From<dtmt_shared::ModDependency> for ModDependency {
fn from(value: dtmt_shared::ModDependency) -> Self {
match value {
dtmt_shared::ModDependency::ID(id) => ModDependency {
id,
order: ModOrder::Before,
},
dtmt_shared::ModDependency::Config { id, order } => ModDependency {
id,
order: match order {
dtmt_shared::ModOrder::Before => ModOrder::Before,
dtmt_shared::ModOrder::After => ModOrder::After,
},
},
}
}
}
#[derive(Clone, Data, Debug, Lens, PartialEq)] #[derive(Clone, Data, Debug, Lens, PartialEq)]
pub(crate) struct ModInfo { pub(crate) struct ModInfo {
pub id: String, pub id: String,
@ -51,6 +81,7 @@ pub(crate) struct ModInfo {
#[lens(ignore)] #[lens(ignore)]
#[data(ignore)] #[data(ignore)]
pub resources: ModResourceInfo, pub resources: ModResourceInfo,
pub depends: Vector<ModDependency>,
} }
impl ModInfo { impl ModInfo {
@ -66,6 +97,7 @@ impl ModInfo {
data: cfg.resources.data, data: cfg.resources.data,
localization: cfg.resources.localization, localization: cfg.resources.localization,
}, },
depends: cfg.depends.into_iter().map(ModDependency::from).collect(),
} }
} }
} }

View file

@ -20,6 +20,7 @@ pub fn error<T: Data>(err: Report, parent: WindowHandle) -> WindowDesc<T> {
let widget = Flex::column() let widget = Flex::column()
.main_axis_alignment(MainAxisAlignment::SpaceBetween) .main_axis_alignment(MainAxisAlignment::SpaceBetween)
.must_fill_main_axis(true)
.cross_axis_alignment(CrossAxisAlignment::End) .cross_axis_alignment(CrossAxisAlignment::End)
.with_child(text) .with_child(text)
.with_spacer(20.) .with_spacer(20.)

View file

@ -1,15 +1,13 @@
use std::path::PathBuf; use std::path::PathBuf;
use color_eyre::eyre;
use color_eyre::Result;
mod log; mod log;
pub use log::*; pub use log::*;
use serde::Deserialize;
use steamlocate::SteamDir; use steamlocate::SteamDir;
use time::OffsetDateTime; use time::OffsetDateTime;
#[derive(Clone, Debug, Default, serde::Deserialize)] #[derive(Clone, Debug, Default, Deserialize)]
pub struct ModConfigResources { pub struct ModConfigResources {
pub init: PathBuf, pub init: PathBuf,
#[serde(default)] #[serde(default)]
@ -18,7 +16,21 @@ pub struct ModConfigResources {
pub localization: Option<PathBuf>, pub localization: Option<PathBuf>,
} }
#[derive(Clone, Debug, Default, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModOrder {
Before,
After,
}
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum ModDependency {
ID(String),
Config { id: String, order: ModOrder },
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct ModConfig { pub struct ModConfig {
#[serde(skip)] #[serde(skip)]
pub dir: std::path::PathBuf, pub dir: std::path::PathBuf,
@ -29,7 +41,7 @@ pub struct ModConfig {
pub packages: Vec<std::path::PathBuf>, pub packages: Vec<std::path::PathBuf>,
pub resources: ModConfigResources, pub resources: ModConfigResources,
#[serde(default)] #[serde(default)]
pub depends: Vec<String>, pub depends: Vec<ModDependency>,
} }
pub const STEAMAPP_ID: u32 = 1361210; pub const STEAMAPP_ID: u32 = 1361210;
@ -40,11 +52,12 @@ pub struct GameInfo {
pub last_updated: OffsetDateTime, pub last_updated: OffsetDateTime,
} }
pub fn collect_game_info() -> Result<GameInfo> { pub fn collect_game_info() -> Option<GameInfo> {
let mut dir = if let Some(dir) = SteamDir::locate() { let mut dir = if let Some(dir) = SteamDir::locate() {
dir dir
} else { } else {
eyre::bail!("Failed to locate Steam installation") tracing::debug!("Failed to locate Steam installation");
return None;
}; };
let found = dir let found = dir
@ -52,15 +65,17 @@ pub fn collect_game_info() -> Result<GameInfo> {
.and_then(|app| app.vdf.get("LastUpdated").map(|v| (app.path.clone(), v))); .and_then(|app| app.vdf.get("LastUpdated").map(|v| (app.path.clone(), v)));
let Some((path, last_updated)) = found else { let Some((path, last_updated)) = found else {
eyre::bail!("Failed to find game installation"); tracing::debug!("Found Steam, but failed to find game installation");
return None;
}; };
let Some(last_updated) = last_updated let Some(last_updated) = last_updated
.as_value() .as_value()
.and_then(|v| v.to::<i64>()) .and_then(|v| v.to::<i64>())
.and_then(|v| OffsetDateTime::from_unix_timestamp(v).ok()) else { .and_then(|v| OffsetDateTime::from_unix_timestamp(v).ok()) else {
eyre::bail!("Couldn't read 'LastUpdate'."); tracing::error!("Found Steam game, but couldn't read 'LastUpdate'.");
return None;
}; };
Ok(GameInfo { path, last_updated }) Some(GameInfo { path, last_updated })
} }