using System.Text; using System.Text.Json; using Theriapolis.Core.Entities; using Theriapolis.Core.Tactical; using Theriapolis.Core.Time; namespace Theriapolis.Core.Persistence; /// /// Read/write the on-disk save format: /// /// [4 bytes: headerLen (uint32 LE)] /// [headerLen bytes: header JSON (UTF-8)] /// [remaining: SaveBody binary blob] /// /// The body is hand-rolled binary rather than MessagePack so we don't pull a /// new nuget into Core. The format is purely additive — any new field becomes /// a new tagged section, which keeps Phase 5/6 expansion smooth. /// public static class SaveCodec { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; // Body section tags. New sections in later phases get new tags >= 100. private const byte TAG_END = 0; private const byte TAG_PLAYER = 1; private const byte TAG_CLOCK = 2; private const byte TAG_CHUNKS = 3; private const byte TAG_WTDELTA = 4; private const byte TAG_FLAGS = 5; // Phase 5 additions: private const byte TAG_CHARACTER = 100; private const byte TAG_NPC_ROSTER = 101; private const byte TAG_ENCOUNTER = 102; // Phase 6 additions: private const byte TAG_FACTION_STANDINGS = 110; private const byte TAG_QUESTS = 111; private const byte TAG_REPUTATION = 112; // 113 reserved for TAG_ANCHORS (Phase 6 M5/save-anywhere if needed). // 114 reserved for TAG_BUILDINGS (Phase 6 M5). public static byte[] Serialize(SaveHeader header, SaveBody body) { // Header JSON string headerJson = JsonSerializer.Serialize(header, JsonOptions); byte[] headerBytes = Encoding.UTF8.GetBytes(headerJson); using var ms = new MemoryStream(); using var w = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true); w.Write((uint)headerBytes.Length); w.Write(headerBytes); // Body WriteBody(w, body); return ms.ToArray(); } public static (SaveHeader header, SaveBody body) Deserialize(byte[] data) { using var ms = new MemoryStream(data); using var r = new BinaryReader(ms, Encoding.UTF8, leaveOpen: true); uint headerLen = r.ReadUInt32(); byte[] headerBytes = r.ReadBytes((int)headerLen); if (headerBytes.Length != headerLen) throw new InvalidDataException("Save header truncated."); string headerJson = Encoding.UTF8.GetString(headerBytes); var header = JsonSerializer.Deserialize(headerJson, JsonOptions) ?? throw new InvalidDataException("Save header empty."); var body = ReadBody(r); return (header, body); } /// Reads ONLY the JSON header, leaving the body unread. Used by the slot picker. public static SaveHeader DeserializeHeaderOnly(byte[] data) { if (data.Length < 4) throw new InvalidDataException("Save file too short."); uint headerLen = BitConverter.ToUInt32(data, 0); if (headerLen + 4 > data.Length) throw new InvalidDataException("Save header truncated."); string headerJson = Encoding.UTF8.GetString(data, 4, (int)headerLen); return JsonSerializer.Deserialize(headerJson, JsonOptions) ?? throw new InvalidDataException("Save header empty."); } // ── Body writer ─────────────────────────────────────────────────────── private static void WriteBody(BinaryWriter w, SaveBody body) { WriteSection(w, TAG_PLAYER, bw => WritePlayer(bw, body.Player)); WriteSection(w, TAG_CLOCK, bw => bw.Write(body.Clock.InGameSeconds)); WriteSection(w, TAG_CHUNKS, bw => WriteChunkDeltas(bw, body.ModifiedChunks)); WriteSection(w, TAG_WTDELTA, bw => WriteWorldTileDeltas(bw, body.ModifiedWorldTiles)); WriteSection(w, TAG_FLAGS, bw => WriteFlags(bw, body.Flags)); if (body.PlayerCharacter is not null) WriteSection(w, TAG_CHARACTER, bw => WriteCharacter(bw, body.PlayerCharacter)); if (body.NpcRoster.ChunkDeltas.Count > 0) WriteSection(w, TAG_NPC_ROSTER, bw => WriteNpcRoster(bw, body.NpcRoster)); if (body.ActiveEncounter is not null) WriteSection(w, TAG_ENCOUNTER, bw => WriteEncounter(bw, body.ActiveEncounter)); // Phase 6 M2 — reputation. Faction standings ride a separate tag from // the personal/ledger payload so a "no personal records yet" save // doesn't pay the cost of an empty section. if (body.ReputationState.FactionStandings.Count > 0) WriteSection(w, TAG_FACTION_STANDINGS, bw => WriteFactionStandings(bw, body.ReputationState.FactionStandings)); if (body.ReputationState.Personal.Count > 0 || body.ReputationState.Ledger.Count > 0) WriteSection(w, TAG_REPUTATION, bw => WriteReputation(bw, body.ReputationState)); // Phase 6 M4 — quest engine snapshot. if (body.QuestEngineState.Active.Count > 0 || body.QuestEngineState.Completed.Count > 0 || body.QuestEngineState.Journal.Count > 0) WriteSection(w, TAG_QUESTS, bw => WriteQuests(bw, body.QuestEngineState)); w.Write(TAG_END); } private static void WriteSection(BinaryWriter w, byte tag, Action body) { w.Write(tag); // Length-prefix the section so unknown tags can be skipped on read. using var ms = new MemoryStream(); using (var inner = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true)) body(inner); var bytes = ms.ToArray(); w.Write((uint)bytes.Length); w.Write(bytes); } private static void WritePlayer(BinaryWriter w, PlayerActorState p) { w.Write(p.Id); WriteString(w, p.Name); w.Write(p.PositionX); w.Write(p.PositionY); w.Write(p.FacingAngleRad); w.Write(p.SpeedWorldPxPerSec); w.Write(p.HighestTierReached); w.Write(p.DiscoveredPoiIds.Length); foreach (int id in p.DiscoveredPoiIds) w.Write(id); } private static void WriteChunkDeltas(BinaryWriter w, IReadOnlyDictionary chunks) { w.Write(chunks.Count); foreach (var kv in chunks) { w.Write(kv.Key.X); w.Write(kv.Key.Y); w.Write(kv.Value.SpawnsConsumed); w.Write(kv.Value.TileMods.Count); foreach (var m in kv.Value.TileMods) { w.Write(m.LocalX); w.Write(m.LocalY); w.Write((byte)m.Surface); w.Write((byte)m.Deco); w.Write(m.Flags); } } } private static void WriteWorldTileDeltas(BinaryWriter w, List tiles) { w.Write(tiles.Count); foreach (var t in tiles) { w.Write(t.X); w.Write(t.Y); w.Write(t.NewBiome); w.Write(t.NewFeatures); } } private static void WriteFlags(BinaryWriter w, Dictionary flags) { w.Write(flags.Count); foreach (var kv in flags) { WriteString(w, kv.Key); w.Write(kv.Value); } } // ── Body reader ─────────────────────────────────────────────────────── private static SaveBody ReadBody(BinaryReader r) { var body = new SaveBody(); while (true) { byte tag = r.ReadByte(); if (tag == TAG_END) break; uint len = r.ReadUInt32(); byte[] sectionBytes = r.ReadBytes((int)len); if (sectionBytes.Length != len) throw new InvalidDataException("Save body truncated."); using var ms = new MemoryStream(sectionBytes); using var br = new BinaryReader(ms); switch (tag) { case TAG_PLAYER: body.Player = ReadPlayer(br); break; case TAG_CLOCK: body.Clock.InGameSeconds = br.ReadInt64(); break; case TAG_CHUNKS: body.ModifiedChunks = ReadChunkDeltas(br); break; case TAG_WTDELTA: body.ModifiedWorldTiles = ReadWorldTileDeltas(br); break; case TAG_FLAGS: body.Flags = ReadFlags(br); break; case TAG_CHARACTER: body.PlayerCharacter = ReadCharacter(br); break; case TAG_NPC_ROSTER: body.NpcRoster = ReadNpcRoster(br); break; case TAG_ENCOUNTER: body.ActiveEncounter = ReadEncounter(br); break; case TAG_FACTION_STANDINGS: body.ReputationState.FactionStandings = ReadFactionStandings(br); break; case TAG_REPUTATION: { var rep = ReadReputation(br); // Faction standings may have been read earlier — preserve them. rep.FactionStandings = body.ReputationState.FactionStandings; body.ReputationState = rep; break; } case TAG_QUESTS: body.QuestEngineState = ReadQuests(br); break; default: /* unknown tag: skip — forward compat */ break; } } return body; } private static PlayerActorState ReadPlayer(BinaryReader r) { var p = new PlayerActorState { Id = r.ReadInt32(), Name = ReadString(r), PositionX = r.ReadSingle(), PositionY = r.ReadSingle(), FacingAngleRad = r.ReadSingle(), SpeedWorldPxPerSec = r.ReadSingle(), HighestTierReached = r.ReadInt32(), }; int n = r.ReadInt32(); p.DiscoveredPoiIds = new int[n]; for (int i = 0; i < n; i++) p.DiscoveredPoiIds[i] = r.ReadInt32(); return p; } private static Dictionary ReadChunkDeltas(BinaryReader r) { var m = new Dictionary(); int n = r.ReadInt32(); for (int i = 0; i < n; i++) { int cx = r.ReadInt32(); int cy = r.ReadInt32(); var d = new ChunkDelta { SpawnsConsumed = r.ReadBoolean() }; int mc = r.ReadInt32(); for (int j = 0; j < mc; j++) { byte lx = r.ReadByte(); byte ly = r.ReadByte(); var sf = (TacticalSurface)r.ReadByte(); var de = (TacticalDeco)r.ReadByte(); byte fl = r.ReadByte(); d.TileMods.Add(new TileMod(lx, ly, sf, de, fl)); } m[new ChunkCoord(cx, cy)] = d; } return m; } private static List ReadWorldTileDeltas(BinaryReader r) { int n = r.ReadInt32(); var l = new List(n); for (int i = 0; i < n; i++) { ushort x = r.ReadUInt16(); ushort y = r.ReadUInt16(); byte b = r.ReadByte(); ushort f = r.ReadUInt16(); l.Add(new WorldTileDelta(x, y, b, f)); } return l; } private static Dictionary ReadFlags(BinaryReader r) { int n = r.ReadInt32(); var d = new Dictionary(n); for (int i = 0; i < n; i++) { string k = ReadString(r); int v = r.ReadInt32(); d[k] = v; } return d; } private static void WriteString(BinaryWriter w, string s) { var bytes = Encoding.UTF8.GetBytes(s ?? ""); w.Write(bytes.Length); w.Write(bytes); } private static string ReadString(BinaryReader r) { int n = r.ReadInt32(); return Encoding.UTF8.GetString(r.ReadBytes(n)); } // ── Phase 5: Character section (TAG_CHARACTER = 100) ───────────────── private static void WriteCharacter(BinaryWriter w, PlayerCharacterState c) { WriteString(w, c.CladeId); WriteString(w, c.SpeciesId); WriteString(w, c.ClassId); WriteString(w, c.BackgroundId); w.Write(c.STR); w.Write(c.DEX); w.Write(c.CON); w.Write(c.INT); w.Write(c.WIS); w.Write(c.CHA); w.Write(c.Level); w.Write(c.Xp); w.Write(c.MaxHp); w.Write(c.CurrentHp); w.Write(c.ExhaustionLevel); w.Write(c.SkillProficiencies.Length); foreach (var s in c.SkillProficiencies) w.Write(s); w.Write(c.Conditions.Length); foreach (var x in c.Conditions) w.Write(x); w.Write(c.Inventory.Length); foreach (var it in c.Inventory) { WriteString(w, it.ItemId); w.Write(it.Qty); w.Write(it.Condition); w.Write(it.EquippedAt.HasValue); if (it.EquippedAt.HasValue) w.Write(it.EquippedAt.Value); } // Phase 5 M6 additions — appended at the end so v5 readers (without // these fields) can still load by short-reading the section. WriteString(w, c.FightingStyle); w.Write(c.RageUsesRemaining); // Phase 6 M3 — currency. Same EOS-check pattern: older saves // without it short-read this section. w.Write(c.CurrencyFang); // Phase 6.5 M0 — subclass + learned features + level-up history. WriteString(w, c.SubclassId); w.Write(c.LearnedFeatureIds.Length); foreach (var fid in c.LearnedFeatureIds) WriteString(w, fid); w.Write(c.LevelUpHistory.Length); foreach (var h in c.LevelUpHistory) { w.Write(h.Level); w.Write(h.HpGained); w.Write(h.HpWasAveraged); w.Write(h.HpHitDieResult); WriteString(w, h.SubclassChosen ?? ""); w.Write(h.AsiKeys.Length); foreach (byte k in h.AsiKeys) w.Write(k); w.Write(h.AsiValues.Length); foreach (int v in h.AsiValues) w.Write(v); w.Write(h.FeaturesUnlocked.Length); foreach (var fid in h.FeaturesUnlocked) WriteString(w, fid); } // Phase 6.5 M4 — hybrid state (optional). EOS-check pattern: write // a presence byte (0/1), then the fields when present. // Phase 6.5 M5 appends ActiveMaskTier (also EOS-checked on read). bool hybridPresent = c.Hybrid is not null; w.Write(hybridPresent); if (hybridPresent) { var h = c.Hybrid!; WriteString(w, h.SireClade); WriteString(w, h.SireSpecies); WriteString(w, h.DamClade); WriteString(w, h.DamSpecies); w.Write(h.DominantParent); w.Write(h.PassingActive); w.Write(h.NpcsWhoKnow.Length); foreach (int npcId in h.NpcsWhoKnow) w.Write(npcId); // Phase 6.5 M5 — mask tier (single byte). w.Write(h.ActiveMaskTier); } } private static PlayerCharacterState ReadCharacter(BinaryReader r) { var c = new PlayerCharacterState { CladeId = ReadString(r), SpeciesId = ReadString(r), ClassId = ReadString(r), BackgroundId = ReadString(r), STR = r.ReadByte(), DEX = r.ReadByte(), CON = r.ReadByte(), INT = r.ReadByte(), WIS = r.ReadByte(), CHA = r.ReadByte(), Level = r.ReadInt32(), Xp = r.ReadInt32(), MaxHp = r.ReadInt32(), CurrentHp = r.ReadInt32(), ExhaustionLevel = r.ReadInt32(), }; int nSkills = r.ReadInt32(); c.SkillProficiencies = new byte[nSkills]; for (int i = 0; i < nSkills; i++) c.SkillProficiencies[i] = r.ReadByte(); int nConds = r.ReadInt32(); c.Conditions = new byte[nConds]; for (int i = 0; i < nConds; i++) c.Conditions[i] = r.ReadByte(); int nInv = r.ReadInt32(); c.Inventory = new InventoryItemState[nInv]; for (int i = 0; i < nInv; i++) { var it = new InventoryItemState { ItemId = ReadString(r), Qty = r.ReadInt32(), Condition = r.ReadInt32(), }; bool hasEquip = r.ReadBoolean(); it.EquippedAt = hasEquip ? r.ReadByte() : null; c.Inventory[i] = it; } // Phase 5 M6 additions — present in saves written by M6+, absent in v5 // saves. The section is length-prefixed so v5 saves stop here. if (r.BaseStream.Position < r.BaseStream.Length) { c.FightingStyle = ReadString(r); c.RageUsesRemaining = r.ReadInt32(); } // Phase 6 M3 addition — currency. Saves without it short-read here. if (r.BaseStream.Position < r.BaseStream.Length) { c.CurrencyFang = r.ReadInt32(); } // Phase 6.5 M0 additions — subclass / features / level-up history. // Same EOS-check pattern: v6 saves without these fields short-read. if (r.BaseStream.Position < r.BaseStream.Length) { c.SubclassId = ReadString(r); int nFeatures = r.ReadInt32(); var features = new string[nFeatures]; for (int i = 0; i < nFeatures; i++) features[i] = ReadString(r); c.LearnedFeatureIds = features; int nHistory = r.ReadInt32(); var history = new LevelUpRecordState[nHistory]; for (int i = 0; i < nHistory; i++) { var h = new LevelUpRecordState { Level = r.ReadInt32(), HpGained = r.ReadInt32(), HpWasAveraged = r.ReadBoolean(), HpHitDieResult = r.ReadInt32(), SubclassChosen = ReadString(r), }; int nKeys = r.ReadInt32(); var keys = new byte[nKeys]; for (int j = 0; j < nKeys; j++) keys[j] = r.ReadByte(); h.AsiKeys = keys; int nVals = r.ReadInt32(); var vals = new int[nVals]; for (int j = 0; j < nVals; j++) vals[j] = r.ReadInt32(); h.AsiValues = vals; int nFids = r.ReadInt32(); var fids = new string[nFids]; for (int j = 0; j < nFids; j++) fids[j] = ReadString(r); h.FeaturesUnlocked = fids; history[i] = h; } c.LevelUpHistory = history; } // Phase 6.5 M4 — hybrid state (optional, EOS-checked). if (r.BaseStream.Position < r.BaseStream.Length) { bool hybridPresent = r.ReadBoolean(); if (hybridPresent) { var h = new HybridStateSnapshot { SireClade = ReadString(r), SireSpecies = ReadString(r), DamClade = ReadString(r), DamSpecies = ReadString(r), DominantParent = r.ReadByte(), PassingActive = r.ReadBoolean(), }; int nKnow = r.ReadInt32(); var knowList = new int[nKnow]; for (int i = 0; i < nKnow; i++) knowList[i] = r.ReadInt32(); h.NpcsWhoKnow = knowList; // Phase 6.5 M5 — mask tier (EOS-checked). if (r.BaseStream.Position < r.BaseStream.Length) h.ActiveMaskTier = r.ReadByte(); c.Hybrid = h; } } return c; } // ── Phase 5 M5: NPC roster + Encounter sections ─────────────────────── private static void WriteNpcRoster(BinaryWriter w, NpcRosterState roster) { w.Write(roster.ChunkDeltas.Count); foreach (var d in roster.ChunkDeltas) { w.Write(d.ChunkX); w.Write(d.ChunkY); w.Write(d.KilledSpawnIndices.Length); foreach (int idx in d.KilledSpawnIndices) w.Write(idx); } } private static NpcRosterState ReadNpcRoster(BinaryReader r) { var roster = new NpcRosterState(); int n = r.ReadInt32(); for (int i = 0; i < n; i++) { var d = new NpcChunkDelta { ChunkX = r.ReadInt32(), ChunkY = r.ReadInt32() }; int m = r.ReadInt32(); d.KilledSpawnIndices = new int[m]; for (int j = 0; j < m; j++) d.KilledSpawnIndices[j] = r.ReadInt32(); roster.ChunkDeltas.Add(d); } return roster; } private static void WriteEncounter(BinaryWriter w, EncounterState enc) { w.Write(enc.EncounterId); w.Write(enc.RollCount); w.Write(enc.CurrentTurnIndex); w.Write(enc.RoundNumber); w.Write(enc.InitiativeOrder.Length); foreach (int i in enc.InitiativeOrder) w.Write(i); w.Write(enc.Combatants.Length); foreach (var c in enc.Combatants) { w.Write(c.Id); WriteString(w, c.Name); w.Write(c.IsPlayer); w.Write(c.NpcChunkX.HasValue); if (c.NpcChunkX.HasValue) w.Write(c.NpcChunkX.Value); w.Write(c.NpcChunkY.HasValue); if (c.NpcChunkY.HasValue) w.Write(c.NpcChunkY.Value); w.Write(c.NpcSpawnIndex.HasValue); if (c.NpcSpawnIndex.HasValue) w.Write(c.NpcSpawnIndex.Value); WriteString(w, c.NpcTemplateId); w.Write(c.CurrentHp); w.Write(c.PositionX); w.Write(c.PositionY); w.Write(c.Conditions.Length); foreach (byte cb in c.Conditions) w.Write(cb); } } private static EncounterState ReadEncounter(BinaryReader r) { var s = new EncounterState { EncounterId = r.ReadUInt64(), RollCount = r.ReadInt32(), CurrentTurnIndex = r.ReadInt32(), RoundNumber = r.ReadInt32(), }; int n = r.ReadInt32(); s.InitiativeOrder = new int[n]; for (int i = 0; i < n; i++) s.InitiativeOrder[i] = r.ReadInt32(); int m = r.ReadInt32(); s.Combatants = new CombatantSnapshot[m]; for (int i = 0; i < m; i++) { var c = new CombatantSnapshot { Id = r.ReadInt32(), Name = ReadString(r), IsPlayer = r.ReadBoolean(), }; if (r.ReadBoolean()) c.NpcChunkX = r.ReadInt32(); if (r.ReadBoolean()) c.NpcChunkY = r.ReadInt32(); if (r.ReadBoolean()) c.NpcSpawnIndex = r.ReadInt32(); c.NpcTemplateId = ReadString(r); c.CurrentHp = r.ReadInt32(); c.PositionX = r.ReadSingle(); c.PositionY = r.ReadSingle(); int cn = r.ReadInt32(); c.Conditions = new byte[cn]; for (int j = 0; j < cn; j++) c.Conditions[j] = r.ReadByte(); s.Combatants[i] = c; } return s; } // ── Phase 5: Schema-version compatibility ───────────────────────────── /// /// Returns true if this binary can load the supplied header's save data. /// Phase 5 refuses any save with version < /// (i.e. Phase-4 saves with no character data). /// public static bool IsCompatible(SaveHeader header) => header.Version >= C.SAVE_SCHEMA_MIN_VERSION; /// Human-readable reason a save is incompatible. Empty when compatible. public static string IncompatibilityReason(SaveHeader header) { if (header.Version < C.SAVE_SCHEMA_MIN_VERSION) return $"This save is from schema v{header.Version}; minimum supported is v{C.SAVE_SCHEMA_MIN_VERSION}. " + "Start a new game from the same seed to play in Phase 5."; return ""; } // ── Phase 6 M2 — reputation ────────────────────────────────────────── private static void WriteFactionStandings(BinaryWriter w, Dictionary standings) { w.Write(standings.Count); foreach (var kv in standings) { WriteString(w, kv.Key); w.Write(kv.Value); } } private static Dictionary ReadFactionStandings(BinaryReader r) { int n = r.ReadInt32(); var d = new Dictionary(n, StringComparer.OrdinalIgnoreCase); for (int i = 0; i < n; i++) { string k = ReadString(r); int v = r.ReadInt32(); d[k] = v; } return d; } private static void WriteReputation(BinaryWriter w, ReputationSnapshot rep) { // Personal records. w.Write(rep.Personal.Count); foreach (var p in rep.Personal) { WriteString(w, p.RoleTag); w.Write(p.Score); w.Write(p.Trust); w.Write(p.Betrayed); w.Write(p.LastInteractionSeconds); w.Write(p.MemoryTags.Length); foreach (var m in p.MemoryTags) WriteString(w, m); w.Write(p.Log.Length); foreach (var ev in p.Log) WriteRepEvent(w, ev); } // Ledger. w.Write(rep.Ledger.Count); foreach (var ev in rep.Ledger) WriteRepEvent(w, ev); } private static ReputationSnapshot ReadReputation(BinaryReader r) { var rep = new ReputationSnapshot(); int n = r.ReadInt32(); for (int i = 0; i < n; i++) { var p = new PersonalDispositionSnapshot { RoleTag = ReadString(r), Score = r.ReadInt32(), Trust = r.ReadByte(), Betrayed = r.ReadBoolean(), LastInteractionSeconds = r.ReadInt64(), }; int mtags = r.ReadInt32(); var memory = new string[mtags]; for (int j = 0; j < mtags; j++) memory[j] = ReadString(r); p.MemoryTags = memory; int logCount = r.ReadInt32(); var log = new RepEventSnapshot[logCount]; for (int j = 0; j < logCount; j++) log[j] = ReadRepEvent(r); p.Log = log; rep.Personal.Add(p); } int ledgerCount = r.ReadInt32(); for (int i = 0; i < ledgerCount; i++) rep.Ledger.Add(ReadRepEvent(r)); return rep; } private static void WriteRepEvent(BinaryWriter w, RepEventSnapshot ev) { w.Write(ev.SequenceId); w.Write(ev.Kind); WriteString(w, ev.FactionId); WriteString(w, ev.RoleTag); w.Write(ev.Magnitude); WriteString(w, ev.Note); w.Write(ev.OriginTileX); w.Write(ev.OriginTileY); w.Write(ev.TimestampSeconds); } private static RepEventSnapshot ReadRepEvent(BinaryReader r) => new() { SequenceId = r.ReadInt32(), Kind = r.ReadByte(), FactionId = ReadString(r), RoleTag = ReadString(r), Magnitude = r.ReadInt32(), Note = ReadString(r), OriginTileX = r.ReadInt32(), OriginTileY = r.ReadInt32(), TimestampSeconds = r.ReadInt64(), }; // ── Phase 6 M4 — quest engine ──────────────────────────────────────── private static void WriteQuests(BinaryWriter w, QuestSnapshot snap) { w.Write(snap.Active.Count); foreach (var s in snap.Active) WriteQuestState(w, s); w.Write(snap.Completed.Count); foreach (var s in snap.Completed) WriteQuestState(w, s); w.Write(snap.Journal.Count); foreach (var line in snap.Journal) WriteString(w, line); } private static QuestSnapshot ReadQuests(BinaryReader r) { var snap = new QuestSnapshot(); int activeCount = r.ReadInt32(); for (int i = 0; i < activeCount; i++) snap.Active.Add(ReadQuestState(r)); int completedCount = r.ReadInt32(); for (int i = 0; i < completedCount; i++) snap.Completed.Add(ReadQuestState(r)); int journalCount = r.ReadInt32(); for (int i = 0; i < journalCount; i++) snap.Journal.Add(ReadString(r)); return snap; } private static void WriteQuestState(BinaryWriter w, QuestStateSnapshot s) { WriteString(w, s.QuestId); WriteString(w, s.CurrentStep); w.Write(s.Status); w.Write(s.StartedAt); w.Write(s.StepStartedAt); w.Write(s.JournalLines.Length); foreach (var line in s.JournalLines) WriteString(w, line); } private static QuestStateSnapshot ReadQuestState(BinaryReader r) { var s = new QuestStateSnapshot { QuestId = ReadString(r), CurrentStep = ReadString(r), Status = r.ReadByte(), StartedAt = r.ReadInt64(), StepStartedAt = r.ReadInt64(), }; int n = r.ReadInt32(); var lines = new string[n]; for (int i = 0; i < n; i++) lines[i] = ReadString(r); s.JournalLines = lines; return s; } }