using System; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; namespace Ashfall.Core { public interface IWorldCodec { string Encode(WorldState world); WorldState Decode(string json); } /// Checksummed snapshots, atomic replacement, three independently readable backups. public sealed class SaveStore { private readonly string path; private readonly IWorldCodec codec; public string LastLoadedPath { get; private set; } public SaveStore(string path, IWorldCodec codec) { this.path = path; this.codec = codec; } private static string Digest(string value) { using (var hash = SHA256.Create()) return Convert.ToBase64String(hash.ComputeHash(Encoding.UTF8.GetBytes(value))); } private string Envelope(WorldState world) { string json = codec.Encode(world); return "ASHFALL/1\n" + Digest(json) + "\n" + json; } public void Save(WorldState world) { Validate(world); string directory = Path.GetDirectoryName(Path.GetFullPath(path)); Directory.CreateDirectory(directory); string temporary = path + ".pending"; byte[] bytes = Encoding.UTF8.GetBytes(Envelope(world)); using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) { stream.Write(bytes, 0, bytes.Length); stream.Flush(true); } // Never rotate a corrupt primary over a known-good backup. bool primaryGood = false; if (File.Exists(path)) { try { Read(path); primaryGood = true; } catch (Exception ex) when (Recoverable(ex)) { } } if (primaryGood) { for (int i = 3; i >= 2; i--) if (File.Exists(path + ".bak" + (i - 1))) File.Copy(path + ".bak" + (i - 1), path + ".bak" + i, true); File.Copy(path, path + ".bak1", true); } if (File.Exists(path)) File.Replace(temporary, path, null); else File.Move(temporary, path); } public WorldState Load() { Exception last = null; foreach (var candidate in new[] { path, path + ".bak1", path + ".bak2", path + ".bak3" }) { if (!File.Exists(candidate)) continue; try { var world = Read(candidate); LastLoadedPath = candidate; return world; } catch (Exception ex) when (Recoverable(ex)) { last = ex; } } throw new InvalidDataException("没有可恢复的有效存档。原文件与备份未被修改。", last); } public bool Exists => File.Exists(path) || Enumerable.Range(1, 3).Any(i => File.Exists(path + ".bak" + i)); private WorldState Read(string candidate) { string data = File.ReadAllText(candidate, Encoding.UTF8); int a = data.IndexOf('\n'), b = a < 0 ? -1 : data.IndexOf('\n', a + 1); if (a < 0 || b < 0 || data.Substring(0, a) != "ASHFALL/1") throw new InvalidDataException("不支持的存档格式"); string json = data.Substring(b + 1), checksum = data.Substring(a + 1, b - a - 1); if (Digest(json) != checksum) throw new InvalidDataException("存档校验失败"); WorldState world; try { world = codec.Decode(json); } catch (Exception ex) { throw new InvalidDataException("无法读取存档数据", ex); } Validate(world); return world; } private static bool Recoverable(Exception ex) => ex is IOException || ex is InvalidDataException || ex is ArgumentException || ex is InvalidOperationException; public static void Validate(WorldState w) { if (w == null || w.version != WorldState.CurrentVersion || w.spaces == null || w.players == null || w.vehicles == null || w.facilities == null || w.portals == null || w.pieces == null || w.enemies == null || w.resources == null || w.research == null || w.warehouse == null || w.shots == null || double.IsNaN(w.time) || double.IsInfinity(w.time) || w.time < 0) throw new InvalidDataException("无效世界快照"); if (w.spaces.Any(s => s == null || string.IsNullOrEmpty(s.id) || s.floors == null || s.floors.Count == 0 || s.floors.Any(f => f == null || f.walls == null || !f.bounds.center.Finite || !f.bounds.size.Finite || f.walls.Any(wall => wall == null))) || w.players.Any(p => p == null || p.input == null) || w.vehicles.Any(v => v == null || v.stations == null || v.hardpoints == null || v.stations.Any(s => s == null || s.location == null) || v.hardpoints.Any(h => h == null) || !v.position.Finite) || w.facilities.Any(f => f == null || f.location == null || f.jobs == null || f.jobs.Any(j => j == null)) || w.portals.Any(p => p == null || p.from == null || p.to == null) || w.pieces.Any(p => p == null || p.location == null) || w.enemies.Any(e => e == null || e.location == null) || w.resources.Any(r => r == null || r.location == null)) throw new InvalidDataException("存档缺少必要实体数据"); if (w.spaces.Select(s => s.id).Distinct().Count() != w.spaces.Count || w.players.Select(p => p.id).Distinct().Count() != w.players.Count) throw new InvalidDataException("重复实体 ID"); if (w.Space("world") == null || w.players.Any(p => p.location == null || !p.location.position.Finite || !Enum.IsDefined(typeof(BroodForm), p.broodForm))) throw new InvalidDataException("无效角色位置或形态"); foreach (var inventory in w.players.Select(p => p.inventory).Concat(w.vehicles.Select(v => v.cargo)).Concat(new[] { w.warehouse })) if (inventory == null || inventory.items == null || inventory.capacity <= 0 || inventory.items.Any(i => i == null || i.amount < 0 || string.IsNullOrEmpty(i.id)) || inventory.items.Sum(i => (long)i.amount) > inventory.capacity || inventory.items.Select(i => i.id).Distinct().Count() != inventory.items.Count) throw new InvalidDataException("无效库存"); } } }