Files
TheriapolisV3/Theriapolis.Godot/Scenes/Steps/StepReview.cs
T
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

174 lines
6.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Godot;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes.Steps;
/// <summary>
/// Step VIII — Sign. Direct port of <c>StepReview</c> in
/// <c>src/steps.jsx</c>: name entry plus the Confirm button that
/// composes the final character and emits the handoff signal per
/// GODOT_PORTING_GUIDE.md §11.
///
/// The full per-step review already lives in the Aside summary, so
/// this step focuses on what's new at this stage: the name input
/// and the Confirm action. Bottom of the page mirrors the React
/// prototype — single big primary button.
/// </summary>
public partial class StepReview : VBoxContainer, IStep
{
/// <summary>Fired when Confirm is pressed and every step validates.
/// The parent Wizard catches this and persists the draft + advances
/// the harness; the rest of the game-side handoff lands in a future
/// milestone (intro scene, world load, etc.).</summary>
[Signal] public delegate void CharacterConfirmedEventHandler(CharacterDraft draft);
private CharacterDraft _draft = null!;
private LineEdit _nameField = null!;
private Button _confirmBtn = null!;
private Label _confirmStatus = null!;
public void Bind(CharacterDraft draft)
{
_draft = draft;
_draft.Changed += () => Callable.From(Refresh).CallDeferred();
Build();
}
public string? Validate() => WizardValidation.Validate(7, _draft);
private void Build()
{
AddThemeConstantOverride("separation", 18);
var intro = new VBoxContainer();
intro.AddThemeConstantOverride("separation", 6);
AddChild(intro);
intro.AddChild(new Label { Text = "FOLIO VIII · SIGN", ThemeTypeVariation = "Eyebrow" });
intro.AddChild(new Label { Text = "Sign the Codex", ThemeTypeVariation = "H2" });
intro.AddChild(new Label
{
Text = "Review the right-rail summary, then sign your name. "
+ "The name you sign here is the one the world will speak.",
AutowrapMode = TextServer.AutowrapMode.WordSmart,
});
// Name field.
var nameBlock = new VBoxContainer();
nameBlock.AddThemeConstantOverride("separation", 6);
AddChild(nameBlock);
nameBlock.AddChild(new Label { Text = "NAME", ThemeTypeVariation = "Eyebrow" });
_nameField = new LineEdit
{
PlaceholderText = "Enter your character's name...",
Text = _draft.CharacterName,
CustomMinimumSize = new Vector2(360, 0),
};
_nameField.TextChanged += OnNameChanged;
nameBlock.AddChild(_nameField);
// Confirm action — disabled until every step validates.
var actionBlock = new VBoxContainer();
actionBlock.AddThemeConstantOverride("separation", 8);
AddChild(actionBlock);
_confirmStatus = new Label
{
Text = "",
AutowrapMode = TextServer.AutowrapMode.WordSmart,
};
actionBlock.AddChild(_confirmStatus);
_confirmBtn = new Button
{
Text = "Confirm & Begin",
CustomMinimumSize = new Vector2(220, 0),
ThemeTypeVariation = "PrimaryButton",
};
_confirmBtn.Pressed += OnConfirmPressed;
actionBlock.AddChild(_confirmBtn);
Refresh();
}
private void OnNameChanged(string newText)
{
// Patch only when the field actually differs from the draft —
// otherwise the Changed signal would refresh us in a loop.
if (newText == _draft.CharacterName) return;
_draft.Patch(new Godot.Collections.Dictionary { { "character_name", newText } });
}
private void Refresh()
{
if (_nameField is null) return;
// Sync the field if external state changed without going through
// the LineEdit (e.g. loading a saved draft someday).
if (_nameField.Text != _draft.CharacterName)
_nameField.Text = _draft.CharacterName;
int firstUnmet = WizardValidation.FirstIncomplete(_draft);
bool allValid = firstUnmet == -1;
_confirmBtn.Disabled = !allValid;
if (allValid)
{
_confirmStatus.Text = $"Ready: {_draft.CharacterName} stands at the threshold.";
}
else if (firstUnmet == 7)
{
_confirmStatus.Text = "Enter a name to sign.";
}
else
{
_confirmStatus.Text = $"Some folios remain — see Folio {Roman(firstUnmet + 1)}.";
}
}
private void OnConfirmPressed()
{
if (WizardValidation.FirstIncomplete(_draft) != -1) return;
// Persist the draft so a future load path can resume editing.
const string DraftPath = "user://character.tres";
var saveErr = ResourceSaver.Save(_draft, DraftPath);
if (saveErr != Error.Ok)
GD.PushWarning($"[review] ResourceSaver.Save failed: {saveErr}");
else
GD.Print($"[review] Saved character draft to {DraftPath}");
// The actual handoff: build the runtime Character via Core's
// CharacterBuilder. Failure here is a content/wiring bug, not a
// user error — the wizard's validation should have caught everything
// the builder rejects. Surface the message and stay on this step.
if (!CharacterAssembler.TryBuild(_draft, out var built, out string buildError))
{
GD.PushError($"[review] Character build failed: {buildError}");
_confirmStatus.Text = $"Could not finalize character — {buildError}";
return;
}
GD.Print($"[review] Confirmed: {Summarise(_draft)} → HP={built!.MaxHp}, "
+ $"hybrid={built.Hybrid is not null}, skills={built.SkillProficiencies.Count}");
EmitSignal(SignalName.CharacterConfirmed, _draft);
_confirmBtn.Disabled = true;
_confirmStatus.Text = $"{_draft.CharacterName} steps into Theriapolis.";
}
private static string Summarise(CharacterDraft d)
{
string lineage = d.IsHybrid
? $"hybrid {d.SireCladeId}/{d.SireSpeciesId} × {d.DamCladeId}/{d.DamSpeciesId} (dom={d.DominantParent})"
: $"{d.CladeId}/{d.SpeciesId}";
return $"{d.CharacterName} — {lineage}, {d.ClassId}/{d.SubclassId}, bg={d.BackgroundId}, "
+ $"skills=[{string.Join(",", d.ChosenSkills)}]";
}
private static string Roman(int n) => n switch
{
1 => "I", 2 => "II", 3 => "III", 4 => "IV",
5 => "V", 6 => "VI", 7 => "VII", 8 => "VIII",
_ => n.ToString(),
};
}