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:
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 < <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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user