169 lines
6.9 KiB
C#
169 lines
6.9 KiB
C#
|
|
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 & 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"));
|
||
|
|
}
|
||
|
|
}
|