using System.Collections.Generic; using System.IO; using System.Text.Json; using Godot; using Theriapolis.Core.Data; using Theriapolis.Core.Items; using Theriapolis.Core.Persistence; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Stats; using Theriapolis.GodotHost.Platform; namespace Theriapolis.GodotHost.UI; /// /// Bridges the wizard's (Godot Resource) into a /// runtime via . Used by /// StepReview's "Confirm & Begin" handoff. /// /// Holds the most-recently-built character in so a /// future PlayScreen / world-load step can pick it up without re-running the /// builder, and writes the captured to /// user://character.json for cold-restart resumability before the M7 /// save format lands. /// public static class CharacterAssembler { /// The most recently confirmed character, or null if none. public static Character? LastBuilt { get; private set; } /// Path used for the resumability dump. public const string PersistedStatePath = "user://character.json"; private static IReadOnlyDictionary? _items; private static IReadOnlyDictionary Items() { if (_items is not null) return _items; var loader = new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir); var arr = loader.LoadItems(); var dict = new Dictionary(arr.Length); foreach (var it in arr) dict[it.Id] = it; _items = dict; return _items; } /// /// Build the runtime from the draft. Returns false /// with a populated when the draft is incomplete /// or content lookups fail; on success holds /// the built object and is updated. /// public static bool TryBuild(CharacterDraft draft, out Character? character, out string error) { character = null; error = ""; var builder = new CharacterBuilder { BaseAbilities = ToAbilityScores(draft.StatAssign), Name = string.IsNullOrWhiteSpace(draft.CharacterName) ? "Wanderer" : draft.CharacterName, }; // Class + background lookups are shared by purebred and hybrid paths. var classDef = CodexContent.Class(draft.ClassId); if (classDef is null) { error = $"Unknown class id '{draft.ClassId}'."; return false; } builder.ClassDef = classDef; var bgDef = CodexContent.Background(draft.BackgroundId); if (bgDef is null) { error = $"Unknown background id '{draft.BackgroundId}'."; return false; } builder.Background = bgDef; foreach (Variant v in draft.ChosenSkills) { string raw = (string)v; try { builder.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch (System.ArgumentException) { error = $"Unknown skill id '{raw}'."; return false; } } Character built; if (draft.IsHybrid) { builder.IsHybridOrigin = true; var sireClade = CodexContent.Clade(draft.SireCladeId); var sireSp = CodexContent.SpeciesById(draft.SireSpeciesId); var damClade = CodexContent.Clade(draft.DamCladeId); var damSp = CodexContent.SpeciesById(draft.DamSpeciesId); if (sireClade is null || sireSp is null || damClade is null || damSp is null) { error = "Hybrid lineage incomplete (sire/dam clade or species missing)."; return false; } builder.HybridSireClade = sireClade; builder.HybridSireSpecies = sireSp; builder.HybridDamClade = damClade; builder.HybridDamSpecies = damSp; builder.HybridDominantParent = draft.DominantParent == "dam" ? ParentLineage.Dam : ParentLineage.Sire; // Hybrid lineage-bonus picks: the wizard's StepClade picker // records one chosen ability per parent. The builder applies // exactly those (no full ability_mods spread, no species mods) // so the built Character matches the Aside's preview math. builder.HybridSireChosenAbility = draft.SireChosenAbility ?? ""; builder.HybridDamChosenAbility = draft.DamChosenAbility ?? ""; if (!builder.TryBuildHybrid(Items(), out var hybridChar, out error)) return false; built = hybridChar!; } else { var clade = CodexContent.Clade(draft.CladeId); var sp = CodexContent.SpeciesById(draft.SpeciesId); if (clade is null || sp is null) { error = "Lineage incomplete (clade or species missing)."; return false; } builder.Clade = clade; builder.Species = sp; try { built = builder.Build(Items()); } catch (System.InvalidOperationException ex) { error = ex.Message; return false; } } // Subclass selection happens at level 3 mechanically, but the wizard // locks the choice in at L1 — stamp it onto the character so the L3 // unlock has the player's pick already recorded. if (!string.IsNullOrEmpty(draft.SubclassId)) built.SubclassId = draft.SubclassId; character = built; LastBuilt = built; PersistState(built); return true; } /// /// Capture the live into a flat /// and write it as JSON to /// . Lets a future cold-load path /// recover the confirmed character before the M7 save format lands. /// private static void PersistState(Character c) { try { var snap = CharacterCodec.Capture(c); string globalPath = ProjectSettings.GlobalizePath(PersistedStatePath); string? dir = Path.GetDirectoryName(globalPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); string json = JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(globalPath, json); GD.Print($"[character] Persisted state to {PersistedStatePath}"); } catch (System.Exception ex) { GD.PushWarning($"[character] PersistState failed: {ex.Message}"); } } private static AbilityScores ToAbilityScores(Godot.Collections.Dictionary statAssign) { int Get(string key) => statAssign.ContainsKey(key) ? (int)statAssign[key] : 10; return new AbilityScores( Get("STR"), Get("DEX"), Get("CON"), Get("INT"), Get("WIS"), Get("CHA")); } }