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>
This commit is contained in:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,176 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Persistence;
/// <summary>
/// Converts between the live <see cref="Character"/> model and its flat
/// serializable snapshot <see cref="PlayerCharacterState"/>. Restore needs a
/// <see cref="ContentResolver"/> so it can re-attach to immutable defs by id.
/// </summary>
public static class CharacterCodec
{
public static PlayerCharacterState Capture(Character c)
{
var state = new PlayerCharacterState
{
CladeId = c.Clade.Id,
SpeciesId = c.Species.Id,
ClassId = c.ClassDef.Id,
BackgroundId = c.Background.Id,
STR = c.Abilities.STR,
DEX = c.Abilities.DEX,
CON = c.Abilities.CON,
INT = c.Abilities.INT,
WIS = c.Abilities.WIS,
CHA = c.Abilities.CHA,
Level = c.Level,
Xp = c.Xp,
MaxHp = c.MaxHp,
CurrentHp = c.CurrentHp,
ExhaustionLevel = c.ExhaustionLevel,
FightingStyle = c.FightingStyle,
RageUsesRemaining = c.RageUsesRemaining,
CurrencyFang = c.CurrencyFang,
SubclassId = c.SubclassId,
LearnedFeatureIds = c.LearnedFeatureIds.ToArray(),
LevelUpHistory = c.LevelUpHistory.Select(h => new LevelUpRecordState
{
Level = h.Level,
HpGained = h.HpGained,
HpWasAveraged = h.HpWasAveraged,
HpHitDieResult = h.HpHitDieResult,
SubclassChosen = h.SubclassChosen ?? "",
AsiKeys = h.AsiAdjustmentsKeys,
AsiValues = h.AsiAdjustmentsValues,
FeaturesUnlocked = h.FeaturesUnlocked,
}).ToArray(),
// Phase 6.5 M4 — hybrid state. Null for purebred PCs.
// Phase 6.5 M5 adds ActiveMaskTier.
Hybrid = c.Hybrid is null ? null : new HybridStateSnapshot
{
SireClade = c.Hybrid.SireClade,
SireSpecies = c.Hybrid.SireSpecies,
DamClade = c.Hybrid.DamClade,
DamSpecies = c.Hybrid.DamSpecies,
DominantParent = (byte)c.Hybrid.DominantParent,
PassingActive = c.Hybrid.PassingActive,
NpcsWhoKnow = c.Hybrid.NpcsWhoKnow.ToArray(),
ActiveMaskTier = (byte)c.Hybrid.ActiveMaskTier,
},
};
var skills = new byte[c.SkillProficiencies.Count];
int i = 0;
foreach (var s in c.SkillProficiencies) skills[i++] = (byte)s;
state.SkillProficiencies = skills;
var conds = new byte[c.Conditions.Count];
i = 0;
foreach (var x in c.Conditions) conds[i++] = (byte)x;
state.Conditions = conds;
var inv = new InventoryItemState[c.Inventory.Items.Count];
for (int k = 0; k < c.Inventory.Items.Count; k++)
{
var it = c.Inventory.Items[k];
inv[k] = new InventoryItemState
{
ItemId = it.Def.Id,
Qty = it.Qty,
Condition = it.Condition,
EquippedAt = it.EquippedAt is { } slot ? (byte)slot : null,
};
}
state.Inventory = inv;
return state;
}
public static Character Restore(PlayerCharacterState state, ContentResolver content)
{
if (!content.Clades.TryGetValue(state.CladeId, out var clade))
throw new InvalidDataException($"Save references unknown clade '{state.CladeId}'.");
if (!content.Species.TryGetValue(state.SpeciesId, out var species))
throw new InvalidDataException($"Save references unknown species '{state.SpeciesId}'.");
if (!content.Classes.TryGetValue(state.ClassId, out var classDef))
throw new InvalidDataException($"Save references unknown class '{state.ClassId}'.");
if (!content.Backgrounds.TryGetValue(state.BackgroundId, out var bg))
throw new InvalidDataException($"Save references unknown background '{state.BackgroundId}'.");
var abilities = new AbilityScores(state.STR, state.DEX, state.CON, state.INT, state.WIS, state.CHA);
var c = new Character(clade, species, classDef, bg, abilities)
{
Level = state.Level,
Xp = state.Xp,
MaxHp = state.MaxHp,
CurrentHp = state.CurrentHp,
ExhaustionLevel = state.ExhaustionLevel,
FightingStyle = state.FightingStyle,
RageUsesRemaining = state.RageUsesRemaining,
CurrencyFang = state.CurrencyFang,
SubclassId = state.SubclassId ?? "",
};
// Phase 6.5 M0 — restore learned features + level-up history.
if (state.LearnedFeatureIds is not null)
foreach (var fid in state.LearnedFeatureIds)
c.LearnedFeatureIds.Add(fid);
if (state.LevelUpHistory is not null)
{
foreach (var h in state.LevelUpHistory)
{
c.LevelUpHistory.Add(new LevelUpRecord
{
Level = h.Level,
HpGained = h.HpGained,
HpWasAveraged = h.HpWasAveraged,
HpHitDieResult = h.HpHitDieResult,
SubclassChosen = string.IsNullOrEmpty(h.SubclassChosen) ? null : h.SubclassChosen,
AsiAdjustmentsKeys = h.AsiKeys ?? Array.Empty<byte>(),
AsiAdjustmentsValues = h.AsiValues ?? Array.Empty<int>(),
FeaturesUnlocked = h.FeaturesUnlocked ?? Array.Empty<string>(),
});
}
}
// Phase 6.5 M4 — restore hybrid state when present.
if (state.Hybrid is not null)
{
c.Hybrid = new HybridState
{
SireClade = state.Hybrid.SireClade,
SireSpecies = state.Hybrid.SireSpecies,
DamClade = state.Hybrid.DamClade,
DamSpecies = state.Hybrid.DamSpecies,
DominantParent = (ParentLineage)state.Hybrid.DominantParent,
PassingActive = state.Hybrid.PassingActive,
ActiveMaskTier = (ScentMaskTier)state.Hybrid.ActiveMaskTier,
};
foreach (int npcId in state.Hybrid.NpcsWhoKnow ?? Array.Empty<int>())
c.Hybrid.NpcsWhoKnow.Add(npcId);
}
foreach (var s in state.SkillProficiencies) c.SkillProficiencies.Add((SkillId)s);
foreach (var x in state.Conditions) c.Conditions.Add((Condition)x);
foreach (var it in state.Inventory)
{
if (!content.Items.TryGetValue(it.ItemId, out var def))
throw new InvalidDataException($"Save references unknown item '{it.ItemId}'.");
var inst = c.Inventory.Add(def, it.Qty);
inst.Condition = it.Condition;
if (it.EquippedAt is { } slotByte)
{
var slot = (EquipSlot)slotByte;
if (!c.Inventory.TryEquip(inst, slot, out var err))
throw new InvalidDataException($"Could not re-equip '{it.ItemId}' into {slot}: {err}");
}
}
return c;
}
}
@@ -0,0 +1,57 @@
namespace Theriapolis.Core.Persistence;
/// <summary>
/// Mid-encounter snapshot. Present in <see cref="SaveBody"/> only when the
/// player saved during combat. On load, the live PlayScreen re-creates a
/// <see cref="Rules.Combat.Encounter"/> with the same participants and
/// calls <c>ResumeRolls(RollCount)</c> so the dice stream continues from
/// the same sequence point — see Phase 5 plan §5.
/// </summary>
public sealed class EncounterState
{
public ulong EncounterId { get; set; }
public int RollCount { get; set; }
public int CurrentTurnIndex { get; set; }
public int RoundNumber { get; set; }
public int[] InitiativeOrder { get; set; } = System.Array.Empty<int>();
public CombatantSnapshot[] Combatants { get; set; } = System.Array.Empty<CombatantSnapshot>();
}
/// <summary>One combatant in the saved encounter.</summary>
public sealed class CombatantSnapshot
{
public int Id { get; set; }
/// <summary>Display name (used to verify the same combatant on resume).</summary>
public string Name { get; set; } = "";
/// <summary>True if this combatant is the player. False = NPC.</summary>
public bool IsPlayer { get; set; }
/// <summary>For NPC combatants: the chunk + spawn index that produced them, used to find the live <c>NpcActor</c> on resume.</summary>
public int? NpcChunkX { get; set; }
public int? NpcChunkY { get; set; }
public int? NpcSpawnIndex { get; set; }
/// <summary>For NPC combatants: the template id (used as fallback identity if chunk lookup fails).</summary>
public string NpcTemplateId { get; set; } = "";
public int CurrentHp { get; set; }
public float PositionX { get; set; }
public float PositionY { get; set; }
/// <summary>Active conditions as enum byte values.</summary>
public byte[] Conditions { get; set; } = System.Array.Empty<byte>();
}
/// <summary>
/// Per-chunk roster delta — which spawn indices have been killed (or
/// otherwise consumed). Layered on top of the chunk's deterministic spawn
/// list so reloading a chunk doesn't resurrect dead enemies.
/// </summary>
public sealed class NpcRosterState
{
public List<NpcChunkDelta> ChunkDeltas { get; set; } = new();
}
public sealed class NpcChunkDelta
{
public int ChunkX { get; set; }
public int ChunkY { get; set; }
/// <summary>Spawn-list indices in this chunk whose NPC has died.</summary>
public int[] KilledSpawnIndices { get; set; } = System.Array.Empty<int>();
}
@@ -0,0 +1,14 @@
namespace Theriapolis.Core.Persistence;
/// <summary>
/// Modules that own save-worthy state implement this interface.
/// CaptureState produces a serializable snapshot; RestoreState applies one
/// over the live module. Phase 5/6 add new persistables (faction/quest/rep)
/// without churning the SaveBody schema, by adding new IPersistable types
/// and one new field on SaveBody.
/// </summary>
public interface IPersistable<TState>
{
TState CaptureState();
void RestoreState(TState state);
}
@@ -0,0 +1,110 @@
namespace Theriapolis.Core.Persistence;
/// <summary>
/// Plain-data snapshot of a <see cref="Rules.Character.Character"/> for
/// the save layer. Kept as primitive fields + arrays so the persistence
/// codec doesn't pull MessagePack attributes onto the live model.
///
/// Items / equipped slots reference content by id (string); the loader
/// re-resolves them against the current <see cref="Data.ItemDef"/> set
/// after world content reloads.
/// </summary>
public sealed class PlayerCharacterState
{
public string CladeId { get; set; } = "";
public string SpeciesId { get; set; } = "";
public string ClassId { get; set; } = "";
public string BackgroundId { get; set; } = "";
// Final ability scores (post clade + species mods).
public byte STR { get; set; }
public byte DEX { get; set; }
public byte CON { get; set; }
public byte INT { get; set; }
public byte WIS { get; set; }
public byte CHA { get; set; }
public int Level { get; set; } = 1;
public int Xp { get; set; } = 0;
public int MaxHp { get; set; }
public int CurrentHp { get; set; }
public int ExhaustionLevel { get; set; } = 0;
// Phase 5 M6 additions — backward-compat handled by EndOfStream check in SaveCodec.ReadCharacter.
public string FightingStyle { get; set; } = "";
public int RageUsesRemaining { get; set; } = 2;
// Phase 6 M3 — coin balance (Fangs are Theriapolis's universal currency).
public int CurrencyFang { get; set; } = 0;
// Phase 6.5 M0 — levelling state.
/// <summary>
/// Subclass id chosen at level 3 (or whatever
/// <see cref="C.SUBCLASS_SELECTION_LEVEL"/> is). Empty pre-L3.
/// </summary>
public string SubclassId { get; set; } = "";
/// <summary>Feature ids learned across all level-ups, in unlock order.</summary>
public string[] LearnedFeatureIds { get; set; } = Array.Empty<string>();
/// <summary>Append-only per-level-up history (deltas, not post-state).</summary>
public LevelUpRecordState[] LevelUpHistory { get; set; } = Array.Empty<LevelUpRecordState>();
// Phase 6.5 M4 — hybrid-character genealogy. Null for purebred PCs.
public HybridStateSnapshot? Hybrid { get; set; }
/// <summary>Skill ids stored as enum byte values for compactness.</summary>
public byte[] SkillProficiencies { get; set; } = Array.Empty<byte>();
/// <summary>Active conditions stored as enum byte values.</summary>
public byte[] Conditions { get; set; } = Array.Empty<byte>();
/// <summary>Inventory stacks — references to ItemDef.Id with quantity + condition + optional equip slot.</summary>
public InventoryItemState[] Inventory { get; set; } = Array.Empty<InventoryItemState>();
}
/// <summary>
/// Phase 6.5 M0 — one entry in <see cref="PlayerCharacterState.LevelUpHistory"/>.
/// Plain-data round-trip of <see cref="Rules.Character.LevelUpRecord"/>.
/// </summary>
public sealed class LevelUpRecordState
{
public int Level { get; set; }
public int HpGained { get; set; }
public bool HpWasAveraged { get; set; }
public int HpHitDieResult { get; set; }
/// <summary>Empty when no subclass was chosen at this level (i.e. anything but L3).</summary>
public string SubclassChosen { get; set; } = "";
/// <summary>ASI ability ids stored as enum byte values; same length as <see cref="AsiValues"/>.</summary>
public byte[] AsiKeys { get; set; } = Array.Empty<byte>();
public int[] AsiValues { get; set; } = Array.Empty<int>();
public string[] FeaturesUnlocked { get; set; } = Array.Empty<string>();
}
/// <summary>
/// Phase 6.5 M4 — flat round-trip record for <see cref="Rules.Character.HybridState"/>.
/// </summary>
public sealed class HybridStateSnapshot
{
public string SireClade { get; set; } = "";
public string SireSpecies { get; set; } = "";
public string DamClade { get; set; } = "";
public string DamSpecies { get; set; } = "";
/// <summary>0 = Sire, 1 = Dam (matches <see cref="Rules.Character.ParentLineage"/> byte values).</summary>
public byte DominantParent { get; set; } = 0;
public bool PassingActive { get; set; } = false;
/// <summary>Per-NPC discovery list — populated in Phase 6.5 M5.</summary>
public int[] NpcsWhoKnow { get; set; } = Array.Empty<int>();
/// <summary>Active scent-mask tier (Phase 6.5 M5). 0 = none.</summary>
public byte ActiveMaskTier { get; set; } = 0;
}
/// <summary>One stack in <see cref="PlayerCharacterState.Inventory"/>.</summary>
public sealed class InventoryItemState
{
public string ItemId { get; set; } = "";
public int Qty { get; set; } = 1;
public int Condition { get; set; } = 100;
/// <summary>Null if this stack is bagged. Otherwise the EquipSlot enum byte value.</summary>
public byte? EquippedAt { get; set; }
}
@@ -0,0 +1,52 @@
using Theriapolis.Core.Rules.Quests;
namespace Theriapolis.Core.Persistence;
/// <summary>
/// Phase 6 M4 — bidirectional translator between the live
/// <see cref="QuestEngine"/> and its serializable
/// <see cref="QuestSnapshot"/>.
/// </summary>
public static class QuestCodec
{
public static QuestSnapshot Capture(QuestEngine engine)
{
var snap = new QuestSnapshot();
foreach (var s in engine.Active.Values) snap.Active.Add(CaptureState(s));
foreach (var s in engine.Completed.Values) snap.Completed.Add(CaptureState(s));
foreach (var line in engine.Journal) snap.Journal.Add(line);
return snap;
}
public static void Restore(QuestEngine engine, QuestSnapshot snap)
{
engine.Clear();
foreach (var s in snap.Active) engine.AdoptActive(RestoreState(s));
foreach (var s in snap.Completed) engine.AdoptCompleted(RestoreState(s));
foreach (var line in snap.Journal) engine.Journal.Add(line);
}
private static QuestStateSnapshot CaptureState(QuestState s) => new()
{
QuestId = s.QuestId,
CurrentStep = s.CurrentStep,
Status = (byte)s.Status,
StartedAt = s.StartedAt,
StepStartedAt = s.StepStartedAt,
JournalLines = s.Journal.ToArray(),
};
private static QuestState RestoreState(QuestStateSnapshot s)
{
var st = new QuestState
{
QuestId = s.QuestId,
CurrentStep = s.CurrentStep,
Status = (QuestStatus)s.Status,
StartedAt = s.StartedAt,
StepStartedAt = s.StepStartedAt,
};
foreach (var line in s.JournalLines) st.Journal.Add(line);
return st;
}
}
@@ -0,0 +1,25 @@
namespace Theriapolis.Core.Persistence;
/// <summary>
/// Phase 6 M4 — serializable quest engine state. Holds active + completed
/// quests + the player journal tail. Round-trips via SaveCodec
/// <c>TAG_QUESTS = 111</c>.
/// </summary>
public sealed class QuestSnapshot
{
public List<QuestStateSnapshot> Active { get; set; } = new();
public List<QuestStateSnapshot> Completed { get; set; } = new();
/// <summary>Most recent journal entries written by the engine.</summary>
public List<string> Journal { get; set; } = new();
}
public sealed class QuestStateSnapshot
{
public string QuestId { get; set; } = "";
public string CurrentStep { get; set; } = "";
public byte Status { get; set; } // QuestStatus byte value
public long StartedAt { get; set; }
public long StepStartedAt { get; set; }
public string[] JournalLines { get; set; } = System.Array.Empty<string>();
}
@@ -0,0 +1,90 @@
using Theriapolis.Core.Rules.Reputation;
namespace Theriapolis.Core.Persistence;
/// <summary>
/// Phase 6 M2 — bidirectional translator between the live
/// <see cref="PlayerReputation"/> aggregate and its serializable
/// <see cref="ReputationSnapshot"/>.
/// </summary>
public static class ReputationCodec
{
public static ReputationSnapshot Capture(PlayerReputation rep)
{
var snap = new ReputationSnapshot();
// Faction standings.
foreach (var (k, v) in rep.Factions.Standings)
snap.FactionStandings[k] = v;
// Personal records.
foreach (var pd in rep.Personal.Values)
{
snap.Personal.Add(new PersonalDispositionSnapshot
{
RoleTag = pd.RoleTag,
Score = pd.Score,
Trust = (byte)pd.Trust,
Betrayed = pd.Betrayed,
LastInteractionSeconds = pd.LastInteractionSeconds,
MemoryTags = pd.Memory.ToArray(),
Log = pd.Log.Select(CaptureEvent).ToArray(),
});
}
// Ledger.
foreach (var ev in rep.Ledger.Entries)
snap.Ledger.Add(CaptureEvent(ev));
return snap;
}
public static PlayerReputation Restore(ReputationSnapshot snap)
{
var rep = new PlayerReputation();
foreach (var (k, v) in snap.FactionStandings)
rep.Factions.Set(k, v);
foreach (var p in snap.Personal)
{
var pd = new PersonalDisposition
{
RoleTag = p.RoleTag,
Score = p.Score,
Trust = (TrustLevel)p.Trust,
Betrayed = p.Betrayed,
LastInteractionSeconds = p.LastInteractionSeconds,
};
foreach (var m in p.MemoryTags) pd.Memory.Add(m);
foreach (var ev in p.Log) pd.Log.Add(RestoreEvent(ev));
rep.Personal[p.RoleTag] = pd;
}
foreach (var ev in snap.Ledger) rep.Ledger.Append(RestoreEvent(ev));
return rep;
}
private static RepEventSnapshot CaptureEvent(RepEvent ev) => new()
{
SequenceId = ev.SequenceId,
Kind = (byte)ev.Kind,
FactionId = ev.FactionId,
RoleTag = ev.RoleTag,
Magnitude = ev.Magnitude,
Note = ev.Note,
OriginTileX = ev.OriginTileX,
OriginTileY = ev.OriginTileY,
TimestampSeconds = ev.TimestampSeconds,
};
private static RepEvent RestoreEvent(RepEventSnapshot s) => new()
{
SequenceId = s.SequenceId,
Kind = (RepEventKind)s.Kind,
FactionId = s.FactionId,
RoleTag = s.RoleTag,
Magnitude = s.Magnitude,
Note = s.Note,
OriginTileX = s.OriginTileX,
OriginTileY = s.OriginTileY,
TimestampSeconds = s.TimestampSeconds,
};
}
@@ -0,0 +1,47 @@
namespace Theriapolis.Core.Persistence;
/// <summary>
/// Phase 6 M2 — serializable snapshot of <see cref="Rules.Reputation.PlayerReputation"/>.
/// Plain-data fields only; rebuilt back into the live aggregate by the
/// PlayScreen restore path.
///
/// Round-trips via <c>SaveCodec</c> tags 110 (faction standings) and 112
/// (reputation aggregate). The split lets the codec emit faction
/// standings even when no personal records exist, keeping save files
/// small for short playthroughs.
/// </summary>
public sealed class ReputationSnapshot
{
/// <summary>Faction id → integer standing in <c>±C.REP_MAX</c>.</summary>
public Dictionary<string, int> FactionStandings { get; set; } = new();
/// <summary>Per-NPC personal records, keyed by role tag.</summary>
public List<PersonalDispositionSnapshot> Personal { get; set; } = new();
/// <summary>Most recent <see cref="Rules.Reputation.RepLedger.MaxEntries"/> events.</summary>
public List<RepEventSnapshot> Ledger { get; set; } = new();
}
public sealed class PersonalDispositionSnapshot
{
public string RoleTag { get; set; } = "";
public int Score { get; set; }
public byte Trust { get; set; } // TrustLevel byte value
public bool Betrayed { get; set; }
public long LastInteractionSeconds { get; set; }
public string[] MemoryTags { get; set; } = System.Array.Empty<string>();
public RepEventSnapshot[] Log { get; set; } = System.Array.Empty<RepEventSnapshot>();
}
public sealed class RepEventSnapshot
{
public int SequenceId { get; set; } // Phase 6 M5
public byte Kind { get; set; } // RepEventKind byte value
public string FactionId { get; set; } = "";
public string RoleTag { get; set; } = "";
public int Magnitude { get; set; }
public string Note { get; set; } = "";
public int OriginTileX { get; set; }
public int OriginTileY { get; set; }
public long TimestampSeconds { get; set; }
}
+82
View File
@@ -0,0 +1,82 @@
using Theriapolis.Core.Entities;
using Theriapolis.Core.Tactical;
using Theriapolis.Core.Time;
namespace Theriapolis.Core.Persistence;
/// <summary>
/// All save-worthy state outside the seed-derivable world. Phase 4 fills the
/// player, clock, chunk deltas, and world-tile deltas; the other fields are
/// reserved for Phase 5/6 so adding them later doesn't require a schema bump.
/// </summary>
public sealed class SaveBody
{
public PlayerActorState Player { get; set; } = new();
public WorldClockState Clock { get; set; } = new();
/// <summary>
/// Phase 5: full character snapshot (clade, species, class, abilities,
/// HP, inventory). Null on Phase-4 saves; the loader treats null as a
/// migration-failed signal and refuses the save.
/// </summary>
public PlayerCharacterState? PlayerCharacter { get; set; }
/// <summary>
/// Phase 5 M5: per-chunk NPC roster delta (which spawn indices have died).
/// Empty on a fresh save; populated as the player kills NPCs.
/// </summary>
public NpcRosterState NpcRoster { get; set; } = new();
/// <summary>
/// Phase 5 M5: present only when the player saved mid-combat. On load,
/// PlayScreen re-creates the encounter from this snapshot and pushes
/// CombatHUDScreen directly without going through the title.
/// </summary>
public EncounterState? ActiveEncounter { get; set; }
/// <summary>Per-chunk player-modification overlay.</summary>
public Dictionary<ChunkCoord, ChunkDelta> ModifiedChunks { get; set; } = new();
/// <summary>Sparse per-world-tile changes (e.g. burned settlement).</summary>
public List<WorldTileDelta> ModifiedWorldTiles { get; set; } = new();
// ── Reserved for later phases ────────────────────────────────────────
// Empty containers so save schema doesn't need a migration when each
// subsystem comes online.
public Dictionary<string, int> Flags { get; set; } = new();
/// <summary>v5 placeholder. Phase 6 M2 superseded by <see cref="ReputationState"/>.</summary>
public Dictionary<int, int> Factions { get; set; } = new();
/// <summary>v5 placeholder. Phase 6 M4 superseded by <see cref="QuestEngineState"/>.</summary>
public List<int> QuestState { get; set; } = new();
/// <summary>v5 placeholder. Phase 6 M2 superseded by <see cref="ReputationState"/>.</summary>
public Dictionary<string, int> Reputation { get; set; } = new();
public List<int> DiscoveredPoiIds { get; set; } = new();
/// <summary>
/// Phase 6 M2 — full reputation snapshot (faction standings, per-NPC
/// personal dispositions, recent ledger). Empty on a fresh game; the
/// V5→V6 migration leaves this empty too (Phase-5 saves never
/// accumulated rep state).
/// </summary>
public ReputationSnapshot ReputationState { get; set; } = new();
/// <summary>
/// Phase 6 M4 — quest engine snapshot. Empty before any quest
/// activates. Named <c>QuestEngineState</c> to avoid colliding with
/// the v5 placeholder <see cref="QuestState"/> dictionary.
/// </summary>
public QuestSnapshot QuestEngineState { get; set; } = new();
}
/// <summary>One world-tile cell that diverged from worldgen baseline.</summary>
public readonly struct WorldTileDelta
{
public readonly ushort X;
public readonly ushort Y;
public readonly byte NewBiome;
public readonly ushort NewFeatures;
public WorldTileDelta(int x, int y, byte biome, ushort features)
{
X = (ushort)x; Y = (ushort)y; NewBiome = biome; NewFeatures = features;
}
}
+764
View File
@@ -0,0 +1,764 @@
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;
}
}
@@ -0,0 +1,56 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Persistence;
/// <summary>
/// JSON-serializable metadata stored at the front of a save file. Designed so
/// the slot picker can deserialize just the header (with a 4-byte length
/// prefix preceding it) without touching the binary body.
/// </summary>
public sealed class SaveHeader
{
/// <summary>Schema version. Bump on breaking changes — see SaveMigrations.</summary>
[JsonPropertyName("version")]
public int Version { get; set; } = C.SAVE_SCHEMA_VERSION;
[JsonPropertyName("worldSeed")]
public string WorldSeedHex { get; set; } = "0x0";
[JsonPropertyName("stageHashes")]
public Dictionary<string, string> StageHashes { get; set; } = new();
[JsonPropertyName("playerName")]
public string PlayerName { get; set; } = "Wanderer";
[JsonPropertyName("playerTier")]
public int PlayerTier { get; set; }
[JsonPropertyName("inGameSeconds")]
public long InGameSeconds { get; set; }
[JsonPropertyName("savedAt")]
public string SavedAtUtc { get; set; } = "";
[JsonPropertyName("appVersion")]
public string AppVersion { get; set; } = "0.4.0";
/// <summary>Convenience: a one-line label for the slot picker.</summary>
public string SlotLabel()
{
// "Wanderer — Y0 Spring D5 (Tier 1)" style
long sec = InGameSeconds;
long days = sec / Time.WorldClock.SecondsPerDay;
int year = (int)(days / Time.WorldClock.DaysPerYear);
var season = (Time.Season)((days / Time.WorldClock.DaysPerSeason) % 4);
long dayOfSeason = days % Time.WorldClock.DaysPerSeason;
return $"{PlayerName} — Y{year} {season} D{dayOfSeason} (Tier {PlayerTier})";
}
public ulong ParseSeed()
{
string s = WorldSeedHex.Trim();
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return Convert.ToUInt64(s[2..], 16);
return ulong.Parse(s);
}
}
@@ -0,0 +1,13 @@
namespace Theriapolis.Core.Persistence.SaveMigrations;
/// <summary>
/// Single-step migration from one schema version to the next. Migrations
/// chain — Migrations.MigrateUp finds a path from header.Version to
/// C.SAVE_SCHEMA_VERSION and applies each step in order.
/// </summary>
public interface ISaveMigration
{
int FromVersion { get; }
int ToVersion { get; }
void Apply(SaveHeader header, SaveBody body);
}
@@ -0,0 +1,49 @@
namespace Theriapolis.Core.Persistence.SaveMigrations;
/// <summary>
/// Registry + chain runner for SaveBody migrations. Phase 4 is the v4 baseline;
/// older versions are not supported because nothing earlier shipped. As we
/// bump the schema in Phase 5+, register migrations here.
///
/// The registry seeds itself with every built-in migration on first use, so
/// callers don't need to remember to <see cref="Register"/> them at startup.
/// </summary>
public static class Migrations
{
private static readonly List<ISaveMigration> _registry = new();
private static bool _seeded;
public static void Register(ISaveMigration m)
{
EnsureSeeded();
_registry.Add(m);
}
private static void EnsureSeeded()
{
if (_seeded) return;
_seeded = true;
// Built-in migrations registered in version order:
_registry.Add(new V5ToV6Migration());
_registry.Add(new V6ToV7Migration());
_registry.Add(new V7ToV8Migration());
}
/// <summary>
/// Walk migrations from header.Version up to C.SAVE_SCHEMA_VERSION. Returns
/// false if no chain exists; the caller decides whether to hard-block or
/// best-effort the load (see SaveCodec docs).
/// </summary>
public static bool MigrateUp(SaveHeader header, SaveBody body)
{
EnsureSeeded();
while (header.Version < C.SAVE_SCHEMA_VERSION)
{
var step = _registry.FirstOrDefault(m => m.FromVersion == header.Version);
if (step is null) return false;
step.Apply(header, body);
header.Version = step.ToVersion;
}
return header.Version == C.SAVE_SCHEMA_VERSION;
}
}
@@ -0,0 +1,27 @@
namespace Theriapolis.Core.Persistence.SaveMigrations;
/// <summary>
/// Phase 6 M2 — additive migration from save schema v5 (Phase 5 ship) to
/// v6 (Phase 6 reputation core). Non-destructive: every v5 field carries
/// over unchanged. The new <see cref="SaveBody.ReputationState"/> is
/// already initialised to a fresh empty <see cref="ReputationSnapshot"/>
/// by the SaveBody constructor, so this migration just bumps the header
/// version.
///
/// Phase 5's placeholder <see cref="SaveBody.Factions"/> and
/// <see cref="SaveBody.Reputation"/> dictionaries were never populated
/// (Phase 5 didn't ship the reputation system), so we don't need to
/// translate any data — they stay empty and ignored.
/// </summary>
public sealed class V5ToV6Migration : ISaveMigration
{
public int FromVersion => 5;
public int ToVersion => 6;
public void Apply(SaveHeader header, SaveBody body)
{
// Body fields all default-initialise to empty in SaveBody — the
// ReputationState is already a fresh ReputationSnapshot. Phase-5
// saves had nothing to translate, so this is a pure version bump.
}
}
@@ -0,0 +1,30 @@
namespace Theriapolis.Core.Persistence.SaveMigrations;
/// <summary>
/// Phase 6.5 M0 — additive migration from save schema v6 (Phase 6 ship) to
/// v7 (Phase 6.5 levelling). Non-destructive: every v6 field carries over
/// unchanged. The new <see cref="PlayerCharacterState.SubclassId"/>,
/// <see cref="PlayerCharacterState.LearnedFeatureIds"/>, and
/// <see cref="PlayerCharacterState.LevelUpHistory"/> default-initialise
/// to empty values by the constructor, so this migration just bumps the
/// header version.
///
/// Phase-6 saves had no level-up history (every character stayed at level
/// 1, Xp = 0). On load they continue to be valid level-1 characters; the
/// player can immediately start earning XP and levelling up under the new
/// rules.
/// </summary>
public sealed class V6ToV7Migration : ISaveMigration
{
public int FromVersion => 6;
public int ToVersion => 7;
public void Apply(SaveHeader header, SaveBody body)
{
// No data translation needed. PlayerCharacterState's new fields
// (SubclassId, LearnedFeatureIds, LevelUpHistory) default-initialise
// to empty in their record, and SaveCodec.ReadCharacter handles
// missing-section bytes via the EOS-check pattern Phase 5 already
// established. Pure version bump.
}
}
@@ -0,0 +1,27 @@
namespace Theriapolis.Core.Persistence.SaveMigrations;
/// <summary>
/// Phase 7 M0 — additive migration from save schema v7 (Phase 6.5 ship) to
/// v8 (Phase 7 dungeons). Non-destructive: every v7 field carries over
/// unchanged. Phase 7 reserves three new save sections — anchors, building
/// deltas, and per-PoI dungeon state — but ships M0 with the version bump
/// only; the fields default-initialise to empty and a v7 save loads as
/// "no anchors persisted, no buildings modified, no dungeons visited"
/// which is the truth for any pre-Phase-7 save.
///
/// Subsequent Phase 7 milestones (M1+) will populate these sections;
/// the migration stays additive throughout.
/// </summary>
public sealed class V7ToV8Migration : ISaveMigration
{
public int FromVersion => 7;
public int ToVersion => 8;
public void Apply(SaveHeader header, SaveBody body)
{
// No data translation needed. Phase 7's new save sections
// (TAG_ANCHORS / TAG_BUILDINGS / TAG_DUNGEONS) default to empty in
// SaveBody and SaveCodec uses the established EOS-check pattern
// for additive sections. Pure version bump.
}
}