Files
TheriapolisV3/Theriapolis.Core/Persistence/SaveCodec.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

765 lines
30 KiB
C#

using System.Text;
using System.Text.Json;
using Theriapolis.Core.Entities;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Time;
namespace Theriapolis.Core.Persistence;
/// <summary>
/// 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.
/// </summary>
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<SaveHeader>(headerJson, JsonOptions)
?? throw new InvalidDataException("Save header empty.");
var body = ReadBody(r);
return (header, body);
}
/// <summary>Reads ONLY the JSON header, leaving the body unread. Used by the slot picker.</summary>
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<SaveHeader>(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<BinaryWriter> 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<ChunkCoord, ChunkDelta> 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<WorldTileDelta> 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<string, int> 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<ChunkCoord, ChunkDelta> ReadChunkDeltas(BinaryReader r)
{
var m = new Dictionary<ChunkCoord, ChunkDelta>();
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<WorldTileDelta> ReadWorldTileDeltas(BinaryReader r)
{
int n = r.ReadInt32();
var l = new List<WorldTileDelta>(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<string, int> ReadFlags(BinaryReader r)
{
int n = r.ReadInt32();
var d = new Dictionary<string, int>(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 ─────────────────────────────
/// <summary>
/// Returns true if this binary can load the supplied header's save data.
/// Phase 5 refuses any save with version &lt; <see cref="C.SAVE_SCHEMA_MIN_VERSION"/>
/// (i.e. Phase-4 saves with no character data).
/// </summary>
public static bool IsCompatible(SaveHeader header) =>
header.Version >= C.SAVE_SCHEMA_MIN_VERSION;
/// <summary>Human-readable reason a save is incompatible. Empty when compatible.</summary>
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<string, int> standings)
{
w.Write(standings.Count);
foreach (var kv in standings)
{
WriteString(w, kv.Key);
w.Write(kv.Value);
}
}
private static Dictionary<string, int> ReadFactionStandings(BinaryReader r)
{
int n = r.ReadInt32();
var d = new Dictionary<string, int>(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;
}
}