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