b451f83174
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>
765 lines
30 KiB
C#
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 < <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;
|
|
}
|
|
}
|