1use std::collections::BTreeMap;
42use std::io::{self, Write};
43use std::path::{Path, PathBuf};
44
45#[derive(Debug, Clone, Copy)]
49pub enum StorageScope<'a> {
50 Global,
52 Player(&'a str),
54 World(&'a str),
56 Entity(&'a str),
58 Chunk(&'a str, i32, i32),
60}
61
62#[derive(Debug, Clone, PartialEq)]
70pub enum Value {
71 Str(String),
72 Int(i64),
73 Float(f64),
74 Bool(bool),
75 Bytes(Vec<u8>),
77}
78
79impl Value {
80 fn to_data(&self) -> yog_data::Value {
82 match self {
83 Value::Str(s) => yog_data::Value::Str(s.clone()),
84 Value::Int(n) => yog_data::Value::Int(*n),
85 Value::Float(f) => yog_data::Value::Float(*f),
86 Value::Bool(b) => yog_data::Value::Bool(*b),
87 Value::Bytes(b) => yog_data::Value::Bytes(b.clone()),
88 }
89 }
90
91 fn from_data(v: &yog_data::Value) -> Option<Value> {
93 Some(match v {
94 yog_data::Value::Str(s) => Value::Str(s.clone()),
95 yog_data::Value::Int(n) => Value::Int(*n),
96 yog_data::Value::Float(f) => Value::Float(*f),
97 yog_data::Value::Bool(b) => Value::Bool(*b),
98 yog_data::Value::Bytes(b) => Value::Bytes(b.clone()),
99 _ => return None,
100 })
101 }
102
103 pub fn as_str(&self) -> Option<&str> {
104 if let Value::Str(s) = self {
105 Some(s)
106 } else {
107 None
108 }
109 }
110
111 pub fn as_int(&self) -> Option<i64> {
113 match self {
114 Value::Int(n) => Some(*n),
115 Value::Float(f) => Some(*f as i64),
116 _ => None,
117 }
118 }
119
120 pub fn as_float(&self) -> Option<f64> {
122 match self {
123 Value::Float(f) => Some(*f),
124 Value::Int(n) => Some(*n as f64),
125 _ => None,
126 }
127 }
128
129 pub fn as_bool(&self) -> Option<bool> {
130 if let Value::Bool(b) = self {
131 Some(*b)
132 } else {
133 None
134 }
135 }
136
137 pub fn as_bytes(&self) -> Option<&[u8]> {
138 if let Value::Bytes(b) = self {
139 Some(b)
140 } else {
141 None
142 }
143 }
144}
145
146impl From<String> for Value {
147 fn from(s: String) -> Self {
148 Value::Str(s)
149 }
150}
151impl From<&str> for Value {
152 fn from(s: &str) -> Self {
153 Value::Str(s.to_string())
154 }
155}
156impl From<i64> for Value {
157 fn from(n: i64) -> Self {
158 Value::Int(n)
159 }
160}
161impl From<i32> for Value {
162 fn from(n: i32) -> Self {
163 Value::Int(n as i64)
164 }
165}
166impl From<u32> for Value {
167 fn from(n: u32) -> Self {
168 Value::Int(n as i64)
169 }
170}
171impl From<u64> for Value {
172 fn from(n: u64) -> Self {
173 Value::Int(n as i64)
174 }
175}
176impl From<usize> for Value {
177 fn from(n: usize) -> Self {
178 Value::Int(n as i64)
179 }
180}
181impl From<f64> for Value {
182 fn from(f: f64) -> Self {
183 Value::Float(f)
184 }
185}
186impl From<f32> for Value {
187 fn from(f: f32) -> Self {
188 Value::Float(f as f64)
189 }
190}
191impl From<bool> for Value {
192 fn from(b: bool) -> Self {
193 Value::Bool(b)
194 }
195}
196impl From<Vec<u8>> for Value {
197 fn from(b: Vec<u8>) -> Self {
198 Value::Bytes(b)
199 }
200}
201
202pub struct Storage {
210 path: PathBuf,
211 data: BTreeMap<String, Value>,
212 dirty: bool,
213}
214
215impl Storage {
216 pub fn open(root: &str, mod_id: &str) -> Self {
220 Self::from_path(scope_path(root, mod_id, StorageScope::Global))
221 }
222
223 pub fn open_scoped(root: &str, mod_id: &str, scope: StorageScope<'_>) -> Self {
225 Self::from_path(scope_path(root, mod_id, scope))
226 }
227
228 pub fn open_player(root: &str, mod_id: &str, player_uuid: &str) -> Self {
230 Self::from_path(scope_path(root, mod_id, StorageScope::Player(player_uuid)))
231 }
232
233 pub fn open_world(root: &str, mod_id: &str, dimension: &str) -> Self {
235 Self::from_path(scope_path(root, mod_id, StorageScope::World(dimension)))
236 }
237
238 pub fn open_entity(root: &str, mod_id: &str, entity_uuid: &str) -> Self {
240 Self::from_path(scope_path(root, mod_id, StorageScope::Entity(entity_uuid)))
241 }
242
243 pub fn open_chunk(root: &str, mod_id: &str, dimension: &str, cx: i32, cz: i32) -> Self {
245 Self::from_path(scope_path(
246 root,
247 mod_id,
248 StorageScope::Chunk(dimension, cx, cz),
249 ))
250 }
251
252 fn from_path(path: PathBuf) -> Self {
253 let data = load_file(&path);
254 Self {
255 path,
256 data,
257 dirty: false,
258 }
259 }
260
261 pub fn get(&self, key: &str) -> Option<&Value> {
264 self.data.get(key)
265 }
266
267 pub fn get_str(&self, key: &str) -> Option<&str> {
268 self.data.get(key)?.as_str()
269 }
270
271 pub fn get_int(&self, key: &str) -> Option<i64> {
272 self.data.get(key)?.as_int()
273 }
274
275 pub fn get_float(&self, key: &str) -> Option<f64> {
276 self.data.get(key)?.as_float()
277 }
278
279 pub fn get_bool(&self, key: &str) -> Option<bool> {
280 self.data.get(key)?.as_bool()
281 }
282
283 pub fn get_bytes(&self, key: &str) -> Option<&[u8]> {
284 self.data.get(key)?.as_bytes()
285 }
286
287 pub fn contains(&self, key: &str) -> bool {
288 self.data.contains_key(key)
289 }
290
291 pub fn set(&mut self, key: impl Into<String>, value: impl Into<Value>) {
298 self.data.insert(key.into(), value.into());
299 self.dirty = true;
300 }
301
302 pub fn remove(&mut self, key: &str) -> Option<Value> {
303 let v = self.data.remove(key);
304 if v.is_some() {
305 self.dirty = true;
306 }
307 v
308 }
309
310 pub fn clear(&mut self) {
311 if !self.data.is_empty() {
312 self.data.clear();
313 self.dirty = true;
314 }
315 }
316
317 pub fn len(&self) -> usize {
320 self.data.len()
321 }
322 pub fn is_empty(&self) -> bool {
323 self.data.is_empty()
324 }
325 pub fn is_dirty(&self) -> bool {
326 self.dirty
327 }
328
329 pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
330 self.data.iter().map(|(k, v)| (k.as_str(), v))
331 }
332
333 pub fn path(&self) -> &Path {
335 &self.path
336 }
337
338 pub fn flush(&mut self) -> io::Result<()> {
345 if let Some(parent) = self.path.parent() {
346 std::fs::create_dir_all(parent)?;
347 }
348 let tmp = self.path.with_extension(TMP_EXT);
349 let map: yog_data::Map = self
350 .data
351 .iter()
352 .map(|(k, v)| (k.as_str(), v.to_data()))
353 .collect();
354 {
355 let mut f = std::fs::File::create(&tmp)?;
356 f.write_all(&yog_data::binary::encode(&map.into()))?;
357 f.flush()?;
358 }
359 std::fs::rename(&tmp, &self.path)?;
360 self.dirty = false;
361 Ok(())
362 }
363}
364
365impl Drop for Storage {
366 fn drop(&mut self) {
367 if self.dirty {
368 let _ = self.flush();
369 }
370 }
371}
372
373const DATA_DIR: &str = "yog-data";
377const STORE_EXT: &str = yog_ids::Format::Data.ext();
378const TMP_EXT: &str = yog_ids::concatcp!(yog_ids::Format::Data.ext(), yog_ids::sep::EXT, "tmp");
380const GLOBAL: &str = "global";
381const PLAYER_DIR: &str = "player";
382const WORLD_DIR: &str = "world";
383const ENTITY_DIR: &str = "entity";
384const CHUNK_DIR: &str = "chunk";
385
386fn scope_path(root: &str, mod_id: &str, scope: StorageScope<'_>) -> PathBuf {
387 let safe_mod = file_safe(mod_id);
388 let base = Path::new(root).join(DATA_DIR).join(safe_mod);
389 let (dir, name) = match scope {
390 StorageScope::Global => (None, GLOBAL.to_string()),
391 StorageScope::Player(uuid) => (Some(PLAYER_DIR), uuid.to_string()),
392 StorageScope::World(dim) => (Some(WORLD_DIR), dim_safe(dim)),
393 StorageScope::Entity(uuid) => (Some(ENTITY_DIR), uuid.to_string()),
394 StorageScope::Chunk(dim, x, z) => (Some(CHUNK_DIR), format!("{}_{x}_{z}", dim_safe(dim))),
395 };
396 let file = format!("{name}.{STORE_EXT}");
397 match dir {
398 Some(dir) => base.join(dir).join(file),
399 None => base.join(file),
400 }
401}
402
403const SAFE_SEP: &str = "_";
405
406fn file_safe(name: &str) -> String {
408 name.replace(yog_ids::sep::NAMESPACE, SAFE_SEP)
409 .replace(yog_ids::sep::PATH, SAFE_SEP)
410}
411
412fn dim_safe(dim: &str) -> String {
413 file_safe(dim)
414}
415
416fn load_file(path: &Path) -> BTreeMap<String, Value> {
417 let Ok(bytes) = std::fs::read(path) else {
418 return BTreeMap::new();
419 };
420 match yog_data::binary::decode(&bytes) {
421 Ok(yog_data::Value::Map(m)) => m
422 .iter()
423 .filter_map(|(k, v)| Value::from_data(v).map(|v| (k.to_string(), v)))
424 .collect(),
425 _ => BTreeMap::new(),
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn game_dir(name: &str) -> String {
434 let d =
435 std::env::temp_dir().join(format!("yog-storage-test-{}-{name}", std::process::id()));
436 let _ = std::fs::remove_dir_all(&d);
437 d.to_string_lossy().into_owned()
438 }
439
440 #[test]
441 fn every_value_comes_back_after_a_flush() {
442 let dir = game_dir("roundtrip");
443 let odd = "tab\there\nnew line \\ backslash";
444 {
445 let mut s = Storage::open(&dir, "mymod");
446 s.set("text", odd);
447 s.set("int", -42i64);
448 s.set("float", 2.5f64);
449 s.set("yes", true);
450 s.set("bytes", vec![0u8, 9, 10, 255]);
451 assert!(s.is_dirty());
452 s.flush().unwrap();
453 assert!(!s.is_dirty());
454 }
455 let s = Storage::open(&dir, "mymod");
456 assert_eq!(s.get_str("text"), Some(odd), "tabs and newlines survive");
457 assert_eq!(s.get_int("int"), Some(-42));
458 assert_eq!(s.get_float("float"), Some(2.5));
459 assert_eq!(s.get_bool("yes"), Some(true));
460 assert_eq!(s.get_bytes("bytes"), Some(&[0u8, 9, 10, 255][..]));
461 assert_eq!(s.len(), 5);
462 let _ = std::fs::remove_dir_all(&dir);
463 }
464
465 #[test]
466 fn dropping_saves_and_scopes_keep_apart() {
467 let dir = game_dir("scopes");
468 {
469 let mut p = Storage::open_player(&dir, "mymod", "uuid-1");
470 p.set("coins", 7i64);
471 let mut c = Storage::open_chunk(&dir, "mymod", "minecraft:overworld", -1, 2);
472 c.set("ore", "copper");
473 }
474 assert_eq!(
475 Storage::open_player(&dir, "mymod", "uuid-1").get_int("coins"),
476 Some(7),
477 "saved on drop"
478 );
479 assert_eq!(
480 Storage::open_player(&dir, "mymod", "uuid-2").get_int("coins"),
481 None
482 );
483 assert_eq!(
484 Storage::open_chunk(&dir, "mymod", "minecraft:overworld", -1, 2).get_str("ore"),
485 Some("copper")
486 );
487 assert!(
488 Storage::open(&dir, "mymod").is_empty(),
489 "the global store is its own"
490 );
491 let _ = std::fs::remove_dir_all(&dir);
492 }
493
494 #[test]
495 fn values_convert() {
496 assert_eq!(Value::from(3u32).as_int(), Some(3));
497 assert_eq!(Value::from("x").as_str(), Some("x"));
498 assert_eq!(Value::from(true).as_int(), None);
499 }
500}