init2
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1054
Cargo.lock
generated
Normal file
1054
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "zomboid-converter"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
tempfile = "3"
|
||||||
|
walkdir = "2"
|
||||||
|
zip = "2"
|
||||||
203
src/convert.rs
Normal file
203
src/convert.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use crate::copy;
|
||||||
|
use crate::input::ResolvedInput;
|
||||||
|
use crate::mods;
|
||||||
|
use crate::password;
|
||||||
|
use crate::players_db;
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub struct ConvertOptions<'a> {
|
||||||
|
pub input: &'a Path,
|
||||||
|
pub output: &'a Path,
|
||||||
|
pub server_name: &'a str,
|
||||||
|
pub server_config: Option<&'a Path>,
|
||||||
|
pub workshop_dir: Option<&'a Path>,
|
||||||
|
pub password: Option<&'a str>,
|
||||||
|
pub env_file: Option<&'a Path>,
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn convert(opts: &ConvertOptions<'_>) -> Result<()> {
|
||||||
|
let resolved = ResolvedInput::resolve(opts.input)?;
|
||||||
|
|
||||||
|
let dest_world = opts
|
||||||
|
.output
|
||||||
|
.join("Saves")
|
||||||
|
.join("Multiplayer")
|
||||||
|
.join(opts.server_name);
|
||||||
|
|
||||||
|
if dest_world.exists() && !opts.force {
|
||||||
|
bail!(
|
||||||
|
"destination world already exists: {} (use --force to replace)",
|
||||||
|
dest_world.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"Converting {} -> {}",
|
||||||
|
resolved.world_root.display(),
|
||||||
|
dest_world.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let copied = copy::copy_world_filtered(&resolved.world_root, &dest_world)?;
|
||||||
|
eprintln!("Copied {copied} files");
|
||||||
|
|
||||||
|
let players_db_path = dest_world.join("players.db");
|
||||||
|
if players_db_path.is_file() {
|
||||||
|
let updated = players_db::patch_world_column(&players_db_path, opts.server_name)?;
|
||||||
|
eprintln!(
|
||||||
|
"Updated {updated} networkPlayers rows (world -> {})",
|
||||||
|
opts.server_name
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!("Warning: no players.db found in destination world");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(config_dir) = opts.server_config {
|
||||||
|
copy_server_config(config_dir, opts.output, &resolved.world_name, opts.server_name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
recover_mods_into_server_ini(opts, &resolved.world_root)?;
|
||||||
|
apply_server_password(opts)?;
|
||||||
|
|
||||||
|
eprintln!("Done.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_server_password(opts: &ConvertOptions<'_>) -> Result<()> {
|
||||||
|
let Some(pw) = password::resolve_password(opts.password, opts.env_file, opts.output)? else {
|
||||||
|
eprintln!(
|
||||||
|
"No server password set (pass --password or SERVER_PASSWORD/PASSWORD in .env); leaving Password= unchanged"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let ini = opts
|
||||||
|
.output
|
||||||
|
.join("Server")
|
||||||
|
.join(format!("{}.ini", opts.server_name));
|
||||||
|
if !ini.is_file() {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: {} missing — cannot apply Password=",
|
||||||
|
ini.display()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
password::patch_ini_key(&ini, "Password", &pw)?;
|
||||||
|
eprintln!("Patched Password= in {}", ini.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_mods_into_server_ini(opts: &ConvertOptions<'_>, world_root: &Path) -> Result<()> {
|
||||||
|
let mod_ids = mods::extract_mod_ids(world_root)?;
|
||||||
|
if mod_ids.is_empty() {
|
||||||
|
eprintln!("No mod IDs found in WorldDictionaryReadable.lua");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Recovered {} mod ID(s) from WorldDictionary", mod_ids.len());
|
||||||
|
|
||||||
|
let Some(workshop_dir) = opts.workshop_dir else {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: --workshop-dir not set; wrote Mods= only. Pass a Steam workshop content/108600 path to also fill WorkshopItems="
|
||||||
|
);
|
||||||
|
let mods_line = mod_ids.join(";");
|
||||||
|
let ini = opts.output.join("Server").join(format!("{}.ini", opts.server_name));
|
||||||
|
if ini.is_file() {
|
||||||
|
// Keep existing WorkshopItems if any
|
||||||
|
let text = fs::read_to_string(&ini)?;
|
||||||
|
let workshop = text
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("WorkshopItems="))
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
mods::patch_server_ini_mods(&ini, &mods_line, &workshop)?;
|
||||||
|
eprintln!("Patched Mods= in {}", ini.display());
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: {} missing — create/start the server once, then re-run with --workshop-dir",
|
||||||
|
ini.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let (workshop_ids, _) = mods::resolve_workshop_ids(&mod_ids, workshop_dir)?;
|
||||||
|
let mods_line = mod_ids.join(";");
|
||||||
|
let workshop_line = workshop_ids.join(";");
|
||||||
|
|
||||||
|
let server_dir = opts.output.join("Server");
|
||||||
|
fs::create_dir_all(&server_dir)?;
|
||||||
|
let ini = server_dir.join(format!("{}.ini", opts.server_name));
|
||||||
|
|
||||||
|
if !ini.is_file() {
|
||||||
|
bail!(
|
||||||
|
"server ini not found at {} — start the dedicated server once to generate it, then re-run",
|
||||||
|
ini.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mods::patch_server_ini_mods(&ini, &mods_line, &workshop_line)?;
|
||||||
|
eprintln!(
|
||||||
|
"Patched {} ({} mods, {} workshop items)",
|
||||||
|
ini.display(),
|
||||||
|
mod_ids.len(),
|
||||||
|
workshop_ids.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_server_config(
|
||||||
|
config_dir: &Path,
|
||||||
|
output: &Path,
|
||||||
|
old_name: &str,
|
||||||
|
server_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let dest_server = output.join("Server");
|
||||||
|
fs::create_dir_all(&dest_server)?;
|
||||||
|
|
||||||
|
let mappings = [
|
||||||
|
(format!("{old_name}.ini"), format!("{server_name}.ini")),
|
||||||
|
(
|
||||||
|
format!("{old_name}_SandboxVars.lua"),
|
||||||
|
format!("{server_name}_SandboxVars.lua"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("{old_name}_spawnpoints.lua"),
|
||||||
|
format!("{server_name}_spawnpoints.lua"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("{old_name}_spawnregions.lua"),
|
||||||
|
format!("{server_name}_spawnregions.lua"),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut copied_any = false;
|
||||||
|
for (src_name, dest_name) in mappings {
|
||||||
|
let src = config_dir.join(&src_name);
|
||||||
|
if src.is_file() {
|
||||||
|
let dest = dest_server.join(&dest_name);
|
||||||
|
fs::copy(&src, &dest).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to copy server config {} -> {}",
|
||||||
|
src.display(),
|
||||||
|
dest.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
eprintln!("Copied server config: {src_name} -> {dest_name}");
|
||||||
|
copied_any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !copied_any {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: no server config files found in {} for world name `{old_name}`",
|
||||||
|
config_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
81
src/copy.rs
Normal file
81
src/copy.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
const SKIP_FILENAMES: &[&str] = &["thumb.png", "InGameMap.ini", "serverid.dat"];
|
||||||
|
|
||||||
|
pub fn copy_world_filtered(source: &Path, dest: &Path) -> Result<u64> {
|
||||||
|
if dest.exists() {
|
||||||
|
fs::remove_dir_all(dest)
|
||||||
|
.with_context(|| format!("failed to remove existing destination: {}", dest.display()))?;
|
||||||
|
}
|
||||||
|
fs::create_dir_all(dest)?;
|
||||||
|
|
||||||
|
let mut copied = 0u64;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(source).follow_links(false) {
|
||||||
|
let entry = entry?;
|
||||||
|
let rel = entry
|
||||||
|
.path()
|
||||||
|
.strip_prefix(source)
|
||||||
|
.context("walkdir path outside source root")?;
|
||||||
|
|
||||||
|
if rel.as_os_str().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_skip(rel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = dest.join(rel);
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&target)?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::copy(entry.path(), &target)?;
|
||||||
|
copied += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(copied)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_skip(rel: &Path) -> bool {
|
||||||
|
let file_name = match rel.file_name().and_then(|n| n.to_str()) {
|
||||||
|
Some(name) => name,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if SKIP_FILENAMES.contains(&file_name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_name.ends_with(".db-journal") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syncthing-style atomic-write conflict copies (e.g. foo.bin-1234567890.123-ZwS1g3qi.bin)
|
||||||
|
if file_name.contains("-ZwS1g3qi") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_conflict_and_journal_files() {
|
||||||
|
assert!(should_skip(Path::new("erosion.ini-1785960551.276219-ZwS1g3qi.ini")));
|
||||||
|
assert!(should_skip(Path::new("players.db-journal")));
|
||||||
|
assert!(should_skip(Path::new("thumb.png")));
|
||||||
|
assert!(!should_skip(Path::new("players.db")));
|
||||||
|
assert!(!should_skip(Path::new("map/1000/1056.bin")));
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/input.rs
Normal file
119
src/input.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::copy;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use zip::ZipArchive;
|
||||||
|
|
||||||
|
/// Resolved world input: a directory path and optional temp dir holding zip extraction.
|
||||||
|
pub struct ResolvedInput {
|
||||||
|
pub world_root: PathBuf,
|
||||||
|
pub world_name: String,
|
||||||
|
_temp_dir: Option<TempDir>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedInput {
|
||||||
|
pub fn resolve(input: &Path) -> Result<Self> {
|
||||||
|
if is_player_folder(input) {
|
||||||
|
bail!(
|
||||||
|
"input looks like a client player cache folder (ends with `_player`): {}",
|
||||||
|
input.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.is_dir() {
|
||||||
|
let world_name = input
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.context("input directory has no name")?
|
||||||
|
.to_string();
|
||||||
|
return Ok(Self {
|
||||||
|
world_root: input.to_path_buf(),
|
||||||
|
world_name,
|
||||||
|
_temp_dir: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.is_file() && input.extension().is_some_and(|e| e == "zip") {
|
||||||
|
return resolve_zip(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!(
|
||||||
|
"input must be a world directory or .zip file: {}",
|
||||||
|
input.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_player_folder(path: &Path) -> bool {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|name| name.ends_with("_player"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_zip(zip_path: &Path) -> Result<ResolvedInput> {
|
||||||
|
let file = fs::File::open(zip_path)
|
||||||
|
.with_context(|| format!("failed to open zip: {}", zip_path.display()))?;
|
||||||
|
let mut archive = ZipArchive::new(file)
|
||||||
|
.with_context(|| format!("failed to read zip: {}", zip_path.display()))?;
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir().context("failed to create temp directory")?;
|
||||||
|
let extract_root = temp_dir.path();
|
||||||
|
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut entry = archive.by_index(i)?;
|
||||||
|
let entry_path = entry
|
||||||
|
.enclosed_name()
|
||||||
|
.with_context(|| format!("zip entry {} has an invalid path", entry.name()))?;
|
||||||
|
|
||||||
|
let out_path = extract_root.join(entry_path);
|
||||||
|
if entry.is_dir() {
|
||||||
|
fs::create_dir_all(&out_path)?;
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut out_file = fs::File::create(&out_path)?;
|
||||||
|
copy(&mut entry, &mut out_file)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let top_level: Vec<_> = fs::read_dir(extract_root)?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if top_level.len() != 1 {
|
||||||
|
bail!(
|
||||||
|
"expected exactly one top-level folder in zip {}, found {}",
|
||||||
|
zip_path.display(),
|
||||||
|
top_level.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let world_root = top_level[0].path();
|
||||||
|
if !world_root.is_dir() {
|
||||||
|
bail!(
|
||||||
|
"expected top-level entry in zip to be a directory: {}",
|
||||||
|
zip_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_player_folder(&world_root) {
|
||||||
|
bail!(
|
||||||
|
"zip contains a client player cache folder (ends with `_player`): {}",
|
||||||
|
world_root.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let world_name = world_root
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.context("extracted world directory has no name")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(ResolvedInput {
|
||||||
|
world_root,
|
||||||
|
world_name,
|
||||||
|
_temp_dir: Some(temp_dir),
|
||||||
|
})
|
||||||
|
}
|
||||||
64
src/main.rs
Normal file
64
src/main.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
mod convert;
|
||||||
|
mod copy;
|
||||||
|
mod input;
|
||||||
|
mod mods;
|
||||||
|
mod password;
|
||||||
|
mod players_db;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "zomboid-converter",
|
||||||
|
about = "Convert a locally hosted Project Zomboid multiplayer world into a dedicated server save layout"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Hosted world directory or .zip archive (not the `_player` folder)
|
||||||
|
#[arg(short, long)]
|
||||||
|
input: PathBuf,
|
||||||
|
|
||||||
|
/// Dedicated server-data root (writes Saves/Multiplayer/<server-name>/)
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: PathBuf,
|
||||||
|
|
||||||
|
/// Target world and config basename (e.g. pzserver)
|
||||||
|
#[arg(short = 'n', long, default_value = "pzserver")]
|
||||||
|
server_name: String,
|
||||||
|
|
||||||
|
/// Optional directory containing hosted Server/ config files to copy and rename
|
||||||
|
#[arg(long)]
|
||||||
|
server_config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Steam workshop content dir for app 108600 (resolves mod IDs → WorkshopItems)
|
||||||
|
#[arg(long)]
|
||||||
|
workshop_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Server join password (Password= in server ini). Overrides .env when set.
|
||||||
|
#[arg(long)]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// Path to .env for SERVER_PASSWORD/PASSWORD (default: <output>/../.env)
|
||||||
|
#[arg(long)]
|
||||||
|
env_file: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Replace an existing destination world folder
|
||||||
|
#[arg(long)]
|
||||||
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
convert::convert(&convert::ConvertOptions {
|
||||||
|
input: &cli.input,
|
||||||
|
output: &cli.output,
|
||||||
|
server_name: &cli.server_name,
|
||||||
|
server_config: cli.server_config.as_deref(),
|
||||||
|
workshop_dir: cli.workshop_dir.as_deref(),
|
||||||
|
password: cli.password.as_deref(),
|
||||||
|
env_file: cli.env_file.as_deref(),
|
||||||
|
force: cli.force,
|
||||||
|
})
|
||||||
|
}
|
||||||
189
src/mods.rs
Normal file
189
src/mods.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const VANILLA_MOD: &str = "pz-vanilla";
|
||||||
|
|
||||||
|
/// Extract unique mod IDs from WorldDictionaryReadable.lua (excludes pz-vanilla).
|
||||||
|
pub fn extract_mod_ids(world_root: &Path) -> Result<Vec<String>> {
|
||||||
|
let dict_path = world_root.join("WorldDictionaryReadable.lua");
|
||||||
|
let text = fs::read_to_string(&dict_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to read WorldDictionaryReadable.lua at {}",
|
||||||
|
dict_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut mods = BTreeSet::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(id) = parse_mod_id_line(line) {
|
||||||
|
if id != VANILLA_MOD {
|
||||||
|
mods.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// damnlib is a common dependency — load it first when present
|
||||||
|
let mut ordered: Vec<String> = mods.into_iter().collect();
|
||||||
|
if let Some(pos) = ordered.iter().position(|m| m == "damnlib") {
|
||||||
|
let lib = ordered.remove(pos);
|
||||||
|
ordered.insert(0, lib);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_mod_id_line(line: &str) -> Option<String> {
|
||||||
|
let key = "modID = \"";
|
||||||
|
let start = line.find(key)? + key.len();
|
||||||
|
let rest = &line[start..];
|
||||||
|
let end = rest.find('"')?;
|
||||||
|
Some(rest[..end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve mod folder IDs to Steam Workshop item IDs by scanning a Steam workshop content dir
|
||||||
|
/// (`.../steamapps/workshop/content/108600`).
|
||||||
|
pub fn resolve_workshop_ids(
|
||||||
|
mod_ids: &[String],
|
||||||
|
workshop_content_dir: &Path,
|
||||||
|
) -> Result<(Vec<String>, Vec<String>)> {
|
||||||
|
let catalog = index_workshop_mods(workshop_content_dir)?;
|
||||||
|
let mut workshop_ids = Vec::new();
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
|
||||||
|
for mod_id in mod_ids {
|
||||||
|
match catalog.get(mod_id) {
|
||||||
|
Some(wid) => {
|
||||||
|
if seen.insert(wid.clone()) {
|
||||||
|
workshop_ids.push(wid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => missing.push(mod_id.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !missing.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"could not resolve Workshop IDs for {} mod(s): {}",
|
||||||
|
missing.len(),
|
||||||
|
missing.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((workshop_ids, missing))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_workshop_mods(workshop_content_dir: &Path) -> Result<HashMap<String, String>> {
|
||||||
|
let mut catalog = HashMap::new();
|
||||||
|
if !workshop_content_dir.is_dir() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"workshop content directory not found: {}",
|
||||||
|
workshop_content_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(workshop_content_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let wid = name.to_string_lossy();
|
||||||
|
if !entry.file_type()?.is_dir() || !wid.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
scan_mod_infos(entry.path().as_path(), &wid, &mut catalog)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(catalog)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_mod_infos(dir: &Path, workshop_id: &str, catalog: &mut HashMap<String, String>) -> Result<()> {
|
||||||
|
let mut stack = vec![dir.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
for entry in fs::read_dir(¤t)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if entry.file_type()?.is_dir() {
|
||||||
|
stack.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if entry.file_name() == "mod.info" {
|
||||||
|
let text = fs::read_to_string(&path)?;
|
||||||
|
if let Some(mod_id) = parse_mod_info_id(&text) {
|
||||||
|
catalog.entry(mod_id).or_insert_with(|| workshop_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_mod_info_id(text: &str) -> Option<String> {
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if let Some(rest) = line.strip_prefix("id=") {
|
||||||
|
return Some(rest.trim().to_string());
|
||||||
|
}
|
||||||
|
// rare: "id = foo"
|
||||||
|
if let Some(rest) = line.strip_prefix("id") {
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
if let Some(rest) = rest.strip_prefix('=') {
|
||||||
|
return Some(rest.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch Mods= and WorkshopItems= in an existing server .ini (preserves other settings).
|
||||||
|
pub fn patch_server_ini_mods(ini_path: &Path, mods: &str, workshop_items: &str) -> Result<()> {
|
||||||
|
let text = fs::read_to_string(ini_path)
|
||||||
|
.with_context(|| format!("failed to read {}", ini_path.display()))?;
|
||||||
|
|
||||||
|
let mut out = String::with_capacity(text.len() + mods.len() + workshop_items.len());
|
||||||
|
let mut saw_mods = false;
|
||||||
|
let mut saw_workshop = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("Mods=") {
|
||||||
|
let _ = rest;
|
||||||
|
out.push_str("Mods=");
|
||||||
|
out.push_str(mods);
|
||||||
|
out.push('\n');
|
||||||
|
saw_mods = true;
|
||||||
|
} else if let Some(rest) = line.strip_prefix("WorkshopItems=") {
|
||||||
|
let _ = rest;
|
||||||
|
out.push_str("WorkshopItems=");
|
||||||
|
out.push_str(workshop_items);
|
||||||
|
out.push('\n');
|
||||||
|
saw_workshop = true;
|
||||||
|
} else {
|
||||||
|
out.push_str(line);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !saw_mods {
|
||||||
|
anyhow::bail!("no Mods= line found in {}", ini_path.display());
|
||||||
|
}
|
||||||
|
if !saw_workshop {
|
||||||
|
anyhow::bail!("no WorkshopItems= line found in {}", ini_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(ini_path, out).with_context(|| format!("failed to write {}", ini_path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_mod_id_line() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_mod_id_line(r#" modID = "damnlib","#),
|
||||||
|
Some("damnlib".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/password.rs
Normal file
131
src/password.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Resolve server join password: CLI override wins, else `.env` keys.
|
||||||
|
pub fn resolve_password(
|
||||||
|
cli_password: Option<&str>,
|
||||||
|
env_file: Option<&Path>,
|
||||||
|
output: &Path,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
if let Some(pw) = cli_password {
|
||||||
|
if !pw.is_empty() {
|
||||||
|
return Ok(Some(pw.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let env_path = match env_file {
|
||||||
|
Some(p) => Some(p.to_path_buf()),
|
||||||
|
None => default_env_path(output),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(env_path) = env_path else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if !env_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vars = parse_env_file(&env_path)?;
|
||||||
|
for key in ["SERVER_PASSWORD", "PASSWORD"] {
|
||||||
|
if let Some(val) = vars.get(key) {
|
||||||
|
if !val.is_empty() {
|
||||||
|
return Ok(Some(val.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_env_path(output: &Path) -> Option<PathBuf> {
|
||||||
|
// output is typically .../server/server-data → sibling .env at .../server/.env
|
||||||
|
let candidate = output.join("..").join(".env");
|
||||||
|
let candidate = candidate.canonicalize().ok().filter(|p| p.is_file());
|
||||||
|
if candidate.is_some() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
let candidate = output.join(".env");
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
|
||||||
|
let text = fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("failed to read env file: {}", path.display()))?;
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = key.trim();
|
||||||
|
let mut value = value.trim().to_string();
|
||||||
|
if (value.starts_with('"') && value.ends_with('"'))
|
||||||
|
|| (value.starts_with('\'') && value.ends_with('\''))
|
||||||
|
{
|
||||||
|
value = value[1..value.len() - 1].to_string();
|
||||||
|
}
|
||||||
|
map.insert(key.to_string(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch a single KEY=value line in a server .ini (exact key prefix match).
|
||||||
|
pub fn patch_ini_key(ini_path: &Path, key: &str, value: &str) -> Result<()> {
|
||||||
|
let text = fs::read_to_string(ini_path)
|
||||||
|
.with_context(|| format!("failed to read {}", ini_path.display()))?;
|
||||||
|
let prefix = format!("{key}=");
|
||||||
|
let mut out = String::with_capacity(text.len() + value.len());
|
||||||
|
let mut saw = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with(&prefix) {
|
||||||
|
out.push_str(&prefix);
|
||||||
|
out.push_str(value);
|
||||||
|
out.push('\n');
|
||||||
|
saw = true;
|
||||||
|
} else {
|
||||||
|
out.push_str(line);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !saw {
|
||||||
|
anyhow::bail!("no {key}= line found in {}", ini_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(ini_path, out).with_context(|| format!("failed to write {}", ini_path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_env_and_prefers_cli() {
|
||||||
|
let mut f = NamedTempFile::new().unwrap();
|
||||||
|
writeln!(f, "SERVER_PASSWORD=fromenv").unwrap();
|
||||||
|
let path = f.path();
|
||||||
|
let vars = parse_env_file(path).unwrap();
|
||||||
|
assert_eq!(vars.get("SERVER_PASSWORD").unwrap(), "fromenv");
|
||||||
|
|
||||||
|
let resolved = resolve_password(Some("clipw"), Some(path), Path::new("/tmp")).unwrap();
|
||||||
|
assert_eq!(resolved.as_deref(), Some("clipw"));
|
||||||
|
|
||||||
|
let resolved = resolve_password(None, Some(path), Path::new("/tmp")).unwrap();
|
||||||
|
assert_eq!(resolved.as_deref(), Some("fromenv"));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/players_db.rs
Normal file
14
src/players_db.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn patch_world_column(players_db: &Path, server_name: &str) -> Result<usize> {
|
||||||
|
let conn = Connection::open(players_db)
|
||||||
|
.with_context(|| format!("failed to open players.db: {}", players_db.display()))?;
|
||||||
|
|
||||||
|
let updated = conn
|
||||||
|
.execute("UPDATE networkPlayers SET world = ?1", [server_name])
|
||||||
|
.with_context(|| "failed to update networkPlayers.world")?;
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user