Files
Christopher Wiebe f7cadaeb68 M6.19: Confirm & Begin → CharacterBuilder handoff + hybrid pick plumbing
The wizard now produces a real runtime Character instead of just
persisting the draft Resource. New Theriapolis.Godot/UI/CharacterAssembler
bridges CharacterDraft → CharacterBuilder, picking the purebred Build
or hybrid TryBuildHybrid path, threading clade/species/class/background
lookups, ability scores, skill picks, dominant-parent, subclass id,
and the items table for the starting kit. The captured
PlayerCharacterState writes to user://character.json (resumability
before the M7 save format lands) and the live Character is held in
CharacterAssembler.LastBuilt for future PlayScreen pickup.

StepReview.OnConfirmPressed surfaces build errors on the status label
instead of crashing, logs HP/hybrid/skill totals on success, and
keeps emitting the existing CharacterConfirmed signal.

Hybrid lineage bonuses now match the wizard's preview math.
CharacterBuilder gains HybridSireChosenAbility / HybridDamChosenAbility;
TryBuildHybrid replaces the old "apply both clades' full mods + both
species mods" blend with "apply each parent's chosen mod only" —
species mods don't apply for hybrids per project decision. Picks
stack additively when both parents land on the same ability
(canidae +1 CON × ursidae +2 CON → +3 CON). Empty pick = no bonus
(defensive fallback for headless builds). HybridCharacterTests'
BlendsAbilityMods rewritten for the new rule, plus two new tests
for stacking and empty-pick fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:26:16 -07:00

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 &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"));
}
}