use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, OnceLock};
pub const LOCAL_PLATFORM_FILE: &str = "platform.json";
pub trait PlatformServices: Send {
fn name(&self) -> &str;
fn save_folder(&self) -> Option<PathBuf> {
None
}
fn unlock_achievement(&mut self, name: &str) -> bool;
fn achievement_unlocked(&self, name: &str) -> bool;
fn set_stat(&mut self, name: &str, value: i32) -> bool;
fn stat(&self, name: &str) -> Option<i32>;
fn workshop_items(&self) -> Vec<PathBuf> {
Vec::new()
}
fn publish_workshop_item(
&mut self,
archive: &Path,
manifest: &crate::mods::ModManifest,
) -> Result<String, String> {
let _ = (archive, manifest);
Err(format!("the `{}` platform has no workshop", self.name()))
}
fn owns_content(&self, id: &str) -> bool {
let _ = id;
true
}
fn open_store_page(&mut self, id: Option<&str>) -> bool {
let _ = id;
false
}
}
#[derive(Debug, Default)]
pub struct LocalPlatform {
folder: Option<PathBuf>,
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct LocalRecord {
#[serde(default)]
achievements: Vec<String>,
#[serde(default)]
stats: BTreeMap<String, i32>,
}
impl LocalPlatform {
#[must_use]
pub fn in_folder(folder: impl Into<PathBuf>) -> Self {
Self {
folder: Some(folder.into()),
}
}
fn path(&self) -> PathBuf {
self.folder
.clone()
.unwrap_or_else(crate::project::default_user_data_folder)
.join(LOCAL_PLATFORM_FILE)
}
fn read(&self) -> LocalRecord {
std::fs::read_to_string(self.path())
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
fn write(&self, record: &LocalRecord) -> bool {
let path = self.path();
let text = match serde_json::to_string_pretty(record) {
Ok(text) => text + "\n",
Err(_) => return false,
};
path.parent()
.is_none_or(|folder| std::fs::create_dir_all(folder).is_ok())
&& crate::runtime::write_atomic_unchecked(path, text.as_bytes())
.is_ok()
}
}
impl PlatformServices for LocalPlatform {
fn name(&self) -> &str {
"local"
}
fn unlock_achievement(&mut self, name: &str) -> bool {
let mut record = self.read();
if record.achievements.iter().any(|known| known == name) {
return true;
}
record.achievements.push(name.to_owned());
record.achievements.sort();
self.write(&record)
}
fn achievement_unlocked(&self, name: &str) -> bool {
self.read().achievements.iter().any(|known| known == name)
}
fn set_stat(&mut self, name: &str, value: i32) -> bool {
let mut record = self.read();
if record.stats.get(name) == Some(&value) {
return true;
}
record.stats.insert(name.to_owned(), value);
self.write(&record)
}
fn stat(&self, name: &str) -> Option<i32> {
self.read().stats.get(name).copied()
}
}
fn services() -> MutexGuard<'static, Box<dyn PlatformServices>> {
static SERVICES: OnceLock<Mutex<Box<dyn PlatformServices>>> =
OnceLock::new();
SERVICES
.get_or_init(|| Mutex::new(Box::new(LocalPlatform::default())))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn set_platform_services(
new: Box<dyn PlatformServices>,
) -> Box<dyn PlatformServices> {
std::mem::replace(&mut *services(), new)
}
#[must_use]
pub fn platform_name() -> String {
services().name().to_owned()
}
#[must_use]
pub fn platform_save_folder() -> Option<PathBuf> {
services().save_folder()
}
pub fn unlock_achievement(name: &str) -> bool {
services().unlock_achievement(name)
}
#[must_use]
pub fn achievement_unlocked(name: &str) -> bool {
services().achievement_unlocked(name)
}
pub fn set_stat(name: &str, value: i32) -> bool {
services().set_stat(name, value)
}
#[must_use]
pub fn stat(name: &str) -> Option<i32> {
services().stat(name)
}
#[must_use]
pub fn workshop_items() -> Vec<PathBuf> {
services().workshop_items()
}
pub fn publish_workshop_item(
archive: &Path,
manifest: &crate::mods::ModManifest,
) -> Result<String, String> {
services().publish_workshop_item(archive, manifest)
}
#[must_use]
pub fn owns_content(id: &str) -> bool {
services().owns_content(id)
}
pub fn open_store_page(id: Option<&str>) -> bool {
services().open_store_page(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct FakeStore {
unlocked: Vec<String>,
}
impl PlatformServices for FakeStore {
fn name(&self) -> &str {
"fake"
}
fn unlock_achievement(&mut self, name: &str) -> bool {
self.unlocked.push(name.to_owned());
true
}
fn achievement_unlocked(&self, name: &str) -> bool {
self.unlocked.iter().any(|known| known == name)
}
fn set_stat(&mut self, _: &str, _: i32) -> bool {
false
}
fn stat(&self, _: &str) -> Option<i32> {
None
}
fn publish_workshop_item(
&mut self,
_: &Path,
manifest: &crate::mods::ModManifest,
) -> Result<String, String> {
Ok(format!("item-{}", manifest.name))
}
fn owns_content(&self, id: &str) -> bool {
id == "soundtrack"
}
}
#[test]
fn local_services_remember_achievements_and_a_store_can_replace_them() {
let folder = std::env::temp_dir()
.join(format!("rusting-platform-{}", uuid::Uuid::new_v4()));
let mut local = LocalPlatform::in_folder(&folder);
assert!(!local.achievement_unlocked("night_5"));
assert!(local.unlock_achievement("night_5"));
assert!(local.unlock_achievement("night_5"), "twice is fine");
assert!(local.set_stat("nights_survived", 5));
assert_eq!(local.stat("missing"), None);
let again = LocalPlatform::in_folder(&folder);
assert!(again.achievement_unlocked("night_5"));
assert_eq!(again.stat("nights_survived"), Some(5));
let text =
std::fs::read_to_string(folder.join(LOCAL_PLATFORM_FILE)).unwrap();
assert_eq!(text.matches("night_5").count(), 1, "{text}");
std::fs::write(folder.join(LOCAL_PLATFORM_FILE), "not json").unwrap();
assert!(!again.achievement_unlocked("night_5"), "broken reads empty");
let _ = std::fs::remove_dir_all(&folder);
let previous = set_platform_services(Box::new(FakeStore::default()));
assert_eq!(platform_name(), "fake");
assert!(unlock_achievement("first_blood"));
assert!(achievement_unlocked("first_blood"));
assert!(!set_stat("kills", 1));
assert_eq!(platform_save_folder(), None);
let manifest = crate::mods::ModManifest {
name: "hats".into(),
..Default::default()
};
let archive = Path::new("hats.rmod");
assert_eq!(
publish_workshop_item(archive, &manifest),
Ok("item-hats".into())
);
assert!(owns_content("soundtrack"));
assert!(!owns_content("expansion"));
assert!(!open_store_page(None));
set_platform_services(previous);
assert_eq!(platform_name(), "local");
assert!(workshop_items().is_empty());
assert!(publish_workshop_item(archive, &manifest)
.unwrap_err()
.contains("no workshop"));
assert!(owns_content("expansion"));
}
}