1use std::collections::BTreeMap;
42use std::io::{self, BufRead, 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 pub fn as_str(&self) -> Option<&str> {
81 if let Value::Str(s) = self {
82 Some(s)
83 } else {
84 None
85 }
86 }
87
88 pub fn as_int(&self) -> Option<i64> {
90 match self {
91 Value::Int(n) => Some(*n),
92 Value::Float(f) => Some(*f as i64),
93 _ => None,
94 }
95 }
96
97 pub fn as_float(&self) -> Option<f64> {
99 match self {
100 Value::Float(f) => Some(*f),
101 Value::Int(n) => Some(*n as f64),
102 _ => None,
103 }
104 }
105
106 pub fn as_bool(&self) -> Option<bool> {
107 if let Value::Bool(b) = self {
108 Some(*b)
109 } else {
110 None
111 }
112 }
113
114 pub fn as_bytes(&self) -> Option<&[u8]> {
115 if let Value::Bytes(b) = self {
116 Some(b)
117 } else {
118 None
119 }
120 }
121}
122
123impl From<String> for Value {
124 fn from(s: String) -> Self {
125 Value::Str(s)
126 }
127}
128impl From<&str> for Value {
129 fn from(s: &str) -> Self {
130 Value::Str(s.to_string())
131 }
132}
133impl From<i64> for Value {
134 fn from(n: i64) -> Self {
135 Value::Int(n)
136 }
137}
138impl From<i32> for Value {
139 fn from(n: i32) -> Self {
140 Value::Int(n as i64)
141 }
142}
143impl From<u32> for Value {
144 fn from(n: u32) -> Self {
145 Value::Int(n as i64)
146 }
147}
148impl From<u64> for Value {
149 fn from(n: u64) -> Self {
150 Value::Int(n as i64)
151 }
152}
153impl From<usize> for Value {
154 fn from(n: usize) -> Self {
155 Value::Int(n as i64)
156 }
157}
158impl From<f64> for Value {
159 fn from(f: f64) -> Self {
160 Value::Float(f)
161 }
162}
163impl From<f32> for Value {
164 fn from(f: f32) -> Self {
165 Value::Float(f as f64)
166 }
167}
168impl From<bool> for Value {
169 fn from(b: bool) -> Self {
170 Value::Bool(b)
171 }
172}
173impl From<Vec<u8>> for Value {
174 fn from(b: Vec<u8>) -> Self {
175 Value::Bytes(b)
176 }
177}
178
179pub struct Storage {
187 path: PathBuf,
188 data: BTreeMap<String, Value>,
189 dirty: bool,
190}
191
192impl Storage {
193 pub fn open(root: &str, mod_id: &str) -> Self {
197 Self::from_path(scope_path(root, mod_id, StorageScope::Global))
198 }
199
200 pub fn open_scoped(root: &str, mod_id: &str, scope: StorageScope<'_>) -> Self {
202 Self::from_path(scope_path(root, mod_id, scope))
203 }
204
205 pub fn open_player(root: &str, mod_id: &str, player_uuid: &str) -> Self {
207 Self::from_path(scope_path(
208 root,
209 mod_id,
210 StorageScope::Player(player_uuid),
211 ))
212 }
213
214 pub fn open_world(root: &str, mod_id: &str, dimension: &str) -> Self {
216 Self::from_path(scope_path(root, mod_id, StorageScope::World(dimension)))
217 }
218
219 pub fn open_entity(root: &str, mod_id: &str, entity_uuid: &str) -> Self {
221 Self::from_path(scope_path(
222 root,
223 mod_id,
224 StorageScope::Entity(entity_uuid),
225 ))
226 }
227
228 pub fn open_chunk(root: &str, mod_id: &str, dimension: &str, cx: i32, cz: i32) -> Self {
230 Self::from_path(scope_path(
231 root,
232 mod_id,
233 StorageScope::Chunk(dimension, cx, cz),
234 ))
235 }
236
237 fn from_path(path: PathBuf) -> Self {
238 let data = load_file(&path);
239 Self {
240 path,
241 data,
242 dirty: false,
243 }
244 }
245
246 pub fn get(&self, key: &str) -> Option<&Value> {
249 self.data.get(key)
250 }
251
252 pub fn get_str(&self, key: &str) -> Option<&str> {
253 self.data.get(key)?.as_str()
254 }
255
256 pub fn get_int(&self, key: &str) -> Option<i64> {
257 self.data.get(key)?.as_int()
258 }
259
260 pub fn get_float(&self, key: &str) -> Option<f64> {
261 self.data.get(key)?.as_float()
262 }
263
264 pub fn get_bool(&self, key: &str) -> Option<bool> {
265 self.data.get(key)?.as_bool()
266 }
267
268 pub fn get_bytes(&self, key: &str) -> Option<&[u8]> {
269 self.data.get(key)?.as_bytes()
270 }
271
272 pub fn contains(&self, key: &str) -> bool {
273 self.data.contains_key(key)
274 }
275
276 pub fn set(&mut self, key: impl Into<String>, value: impl Into<Value>) {
283 self.data.insert(key.into(), value.into());
284 self.dirty = true;
285 }
286
287 pub fn remove(&mut self, key: &str) -> Option<Value> {
288 let v = self.data.remove(key);
289 if v.is_some() {
290 self.dirty = true;
291 }
292 v
293 }
294
295 pub fn clear(&mut self) {
296 if !self.data.is_empty() {
297 self.data.clear();
298 self.dirty = true;
299 }
300 }
301
302 pub fn len(&self) -> usize {
305 self.data.len()
306 }
307 pub fn is_empty(&self) -> bool {
308 self.data.is_empty()
309 }
310 pub fn is_dirty(&self) -> bool {
311 self.dirty
312 }
313
314 pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
315 self.data.iter().map(|(k, v)| (k.as_str(), v))
316 }
317
318 pub fn path(&self) -> &Path {
320 &self.path
321 }
322
323 pub fn flush(&mut self) -> io::Result<()> {
330 if let Some(parent) = self.path.parent() {
331 std::fs::create_dir_all(parent)?;
332 }
333 let tmp = self.path.with_extension("kv.tmp");
334 {
335 let mut f = std::fs::File::create(&tmp)?;
336 writeln!(f, "# yog-storage v2")?;
337 for (k, v) in &self.data {
338 let (typ, enc) = encode_value(v);
339 writeln!(f, "{}\t{}\t{}", str_escape(k), typ, enc)?;
340 }
341 f.flush()?;
342 }
343 std::fs::rename(&tmp, &self.path)?;
344 self.dirty = false;
345 Ok(())
346 }
347}
348
349impl Drop for Storage {
350 fn drop(&mut self) {
351 if self.dirty {
352 let _ = self.flush();
353 }
354 }
355}
356
357fn scope_path(root: &str, mod_id: &str, scope: StorageScope<'_>) -> PathBuf {
360 let safe_mod = mod_id.replace([':', '/'], "_");
361 let base = Path::new(root).join("yog-data").join(safe_mod);
362 match scope {
363 StorageScope::Global => base.join("global.kv"),
364 StorageScope::Player(uuid) => base.join("player").join(format!("{uuid}.kv")),
365 StorageScope::World(dim) => base.join("world").join(format!("{}.kv", dim_safe(dim))),
366 StorageScope::Entity(uuid) => base.join("entity").join(format!("{uuid}.kv")),
367 StorageScope::Chunk(dim, x, z) => base
368 .join("chunk")
369 .join(format!("{}_{x}_{z}.kv", dim_safe(dim))),
370 }
371}
372
373fn dim_safe(dim: &str) -> String {
374 dim.replace([':', '/'], "_")
375}
376
377fn load_file(path: &Path) -> BTreeMap<String, Value> {
378 let file = match std::fs::File::open(path) {
379 Ok(f) => f,
380 Err(_) => return BTreeMap::new(),
381 };
382 let mut map = BTreeMap::new();
383 for line in io::BufReader::new(file).lines() {
384 let Ok(line) = line else { continue };
385 if line.is_empty() || line.starts_with('#') {
386 continue;
387 }
388 let mut cols = line.splitn(3, '\t');
389 let (Some(raw_k), Some(typ), Some(raw_v)) = (cols.next(), cols.next(), cols.next()) else {
390 continue;
391 };
392 if let Some(v) = parse_value(typ, raw_v) {
393 map.insert(str_unescape(raw_k), v);
394 }
395 }
396 map
397}
398
399fn parse_value(typ: &str, raw: &str) -> Option<Value> {
400 match typ {
401 "s" => Some(Value::Str(str_unescape(raw))),
402 "i" => raw.parse::<i64>().ok().map(Value::Int),
403 "f" => raw.parse::<f64>().ok().map(Value::Float),
404 "b" => Some(Value::Bool(raw == "1")),
405 "x" => Some(Value::Bytes(hex_decode(raw))),
406 _ => None,
407 }
408}
409
410fn encode_value(v: &Value) -> (&'static str, String) {
411 match v {
412 Value::Str(s) => ("s", str_escape(s)),
413 Value::Int(n) => ("i", n.to_string()),
414 Value::Float(f) => ("f", f.to_string()),
415 Value::Bool(b) => ("b", if *b { "1" } else { "0" }.to_string()),
416 Value::Bytes(b) => ("x", hex_encode(b)),
417 }
418}
419
420fn str_escape(s: &str) -> String {
421 let mut out = String::with_capacity(s.len() + 4);
422 for c in s.chars() {
423 match c {
424 '\\' => out.push_str(r"\\"),
425 '\t' => out.push_str(r"\t"),
426 '\n' => out.push_str(r"\n"),
427 '\r' => out.push_str(r"\r"),
428 c => out.push(c),
429 }
430 }
431 out
432}
433
434fn str_unescape(s: &str) -> String {
435 let mut out = String::with_capacity(s.len());
436 let mut chars = s.chars();
437 while let Some(c) = chars.next() {
438 if c == '\\' {
439 match chars.next() {
440 Some('\\') => out.push('\\'),
441 Some('t') => out.push('\t'),
442 Some('n') => out.push('\n'),
443 Some('r') => out.push('\r'),
444 Some(c) => {
445 out.push('\\');
446 out.push(c);
447 }
448 None => out.push('\\'),
449 }
450 } else {
451 out.push(c);
452 }
453 }
454 out
455}
456
457fn hex_encode(b: &[u8]) -> String {
458 use std::fmt::Write as FmtWrite;
459 b.iter()
460 .fold(String::with_capacity(b.len() * 2), |mut s, byte| {
461 let _ = write!(s, "{byte:02x}");
462 s
463 })
464}
465
466fn hex_decode(s: &str) -> Vec<u8> {
467 (0..s.len())
468 .step_by(2)
469 .filter_map(|i| s.get(i..i + 2))
470 .filter_map(|h| u8::from_str_radix(h, 16).ok())
471 .collect()
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 fn game_dir(name: &str) -> String {
479 let d = std::env::temp_dir().join(format!("yog-storage-test-{}-{name}", std::process::id()));
480 let _ = std::fs::remove_dir_all(&d);
481 d.to_string_lossy().into_owned()
482 }
483
484 #[test]
485 fn every_value_comes_back_after_a_flush() {
486 let dir = game_dir("roundtrip");
487 let odd = "tab\there\nnew line \\ backslash";
488 {
489 let mut s = Storage::open(&dir, "mymod");
490 s.set("text", odd);
491 s.set("int", -42i64);
492 s.set("float", 2.5f64);
493 s.set("yes", true);
494 s.set("bytes", vec![0u8, 9, 10, 255]);
495 assert!(s.is_dirty());
496 s.flush().unwrap();
497 assert!(!s.is_dirty());
498 }
499 let s = Storage::open(&dir, "mymod");
500 assert_eq!(s.get_str("text"), Some(odd), "tabs and newlines survive");
501 assert_eq!(s.get_int("int"), Some(-42));
502 assert_eq!(s.get_float("float"), Some(2.5));
503 assert_eq!(s.get_bool("yes"), Some(true));
504 assert_eq!(s.get_bytes("bytes"), Some(&[0u8, 9, 10, 255][..]));
505 assert_eq!(s.len(), 5);
506 let _ = std::fs::remove_dir_all(&dir);
507 }
508
509 #[test]
510 fn dropping_saves_and_scopes_keep_apart() {
511 let dir = game_dir("scopes");
512 {
513 let mut p = Storage::open_player(&dir, "mymod", "uuid-1");
514 p.set("coins", 7i64);
515 let mut c = Storage::open_chunk(&dir, "mymod", "minecraft:overworld", -1, 2);
516 c.set("ore", "copper");
517 }
518 assert_eq!(Storage::open_player(&dir, "mymod", "uuid-1").get_int("coins"), Some(7), "saved on drop");
519 assert_eq!(Storage::open_player(&dir, "mymod", "uuid-2").get_int("coins"), None);
520 assert_eq!(Storage::open_chunk(&dir, "mymod", "minecraft:overworld", -1, 2).get_str("ore"), Some("copper"));
521 assert!(Storage::open(&dir, "mymod").is_empty(), "the global store is its own");
522 let _ = std::fs::remove_dir_all(&dir);
523 }
524
525 #[test]
526 fn values_convert() {
527 assert_eq!(Value::from(3u32).as_int(), Some(3));
528 assert_eq!(Value::from("x").as_str(), Some("x"));
529 assert_eq!(Value::from(true).as_int(), None);
530 }
531}