Files

169 lines
6.9 KiB
C#
Raw Permalink Normal View History

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;
/// <summary>
/// Bridges the wizard's <see cref="CharacterDraft"/> (Godot Resource) into a
/// runtime <see cref="Character"/> via <see cref="CharacterBuilder"/>. Used by
/// StepReview's "Confirm &amp; Begin" handoff.
///
/// Holds the most-recently-built character in <see cref="LastBuilt"/> so a
/// future PlayScreen / world-load step can pick it up without re-running the
/// builder, and writes the captured <see cref="PlayerCharacterState"/> to
/// <c>user://character.json</c> for cold-restart resumability before the M7
/// save format lands.
/// </summary>
public static class CharacterAssembler
{
/// <summary>The most recently confirmed character, or null if none.</summary>
public static Character? LastBuilt { get; private set; }
/// <summary>Path used for the resumability dump.</summary>
public const string PersistedStatePath = "user://character.json";
private static IReadOnlyDictionary<string, ItemDef>? _items;
private static IReadOnlyDictionary<string, ItemDef> 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<string, ItemDef>(arr.Length);
foreach (var it in arr) dict[it.Id] = it;
_items = dict;
return _items;
}
/// <summary>
/// Build the runtime <see cref="Character"/> from the draft. Returns false
/// with a populated <paramref name="error"/> when the draft is incomplete
/// or content lookups fail; on success <paramref name="character"/> holds
/// the built object and <see cref="LastBuilt"/> is updated.
/// </summary>
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;
}
/// <summary>
/// Capture the live <see cref="Character"/> into a flat
/// <see cref="PlayerCharacterState"/> and write it as JSON to
/// <see cref="PersistedStatePath"/>. Lets a future cold-load path
/// recover the confirmed character before the M7 save format lands.
/// </summary>
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"));
}
}