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