namespace Theriapolis.GodotHost.UI; /// /// Per-step validation against a CharacterDraft. Mirrors app.jsx's /// validate(i) exactly: returns null when step i's /// requirements are met, or a short error message otherwise. Static so /// the Wizard can ask "is step N satisfied?" without instantiating each /// step's UI — needed to compute Locked/Pending states across the whole /// flow, not just the current step. /// /// React prototype's app.jsx logic: /// firstIncomplete = STEPS.findIndex(j => validate(j)); /// locked = i > step && firstIncomplete !== -1 && firstIncomplete < i; /// /// (i.e. a step is locked iff some EARLIER step fails its validator). /// public static class WizardValidation { public static string? Validate(int step, CharacterDraft draft) => step switch { 0 => ValidateClade(draft), 1 => ValidateSpecies(draft), 2 => string.IsNullOrEmpty(draft.ClassId) ? "Pick a calling." : null, 3 => string.IsNullOrEmpty(draft.SubclassId) ? "Pick a subclass." : null, 4 => string.IsNullOrEmpty(draft.BackgroundId) ? "Pick a history." : null, 5 => draft.StatAssign.Count == 6 ? null : $"Assign all six abilities ({draft.StatAssign.Count}/6).", 6 => ValidateSkills(draft), 7 => string.IsNullOrWhiteSpace(draft.CharacterName) ? "Enter a name." : null, _ => null, }; private static string? ValidateClade(CharacterDraft draft) { if (string.IsNullOrEmpty(draft.Sex)) return "Pick a sex."; if (draft.IsHybrid) { if (string.IsNullOrEmpty(draft.SireCladeId)) return "Pick a sire clade."; if (string.IsNullOrEmpty(draft.DamCladeId)) return "Pick a dam clade."; if (draft.SireCladeId == draft.DamCladeId) return "Sire and dam must be different clades."; // Pick one ability mod from each parent clade. Stacking on // the same ability is allowed (the rule was simplified to // permit duplicate picks summing). if (string.IsNullOrEmpty(draft.SireChosenAbility)) return "Pick a lineage bonus from the sire clade."; if (string.IsNullOrEmpty(draft.DamChosenAbility)) return "Pick a lineage bonus from the dam clade."; // Phase B: 2 clade traits from dominant + 1 from non-dominant. int sireNeed = draft.CladeTraitLimit("sire"); int damNeed = draft.CladeTraitLimit("dam"); if (draft.SireChosenCladeTraits.Count != sireNeed) return $"Pick {sireNeed} sire clade trait{(sireNeed == 1 ? "" : "s")} ({draft.SireChosenCladeTraits.Count}/{sireNeed})."; if (draft.DamChosenCladeTraits.Count != damNeed) return $"Pick {damNeed} dam clade trait{(damNeed == 1 ? "" : "s")} ({draft.DamChosenCladeTraits.Count}/{damNeed})."; return null; } return string.IsNullOrEmpty(draft.CladeId) ? "Pick a clade." : null; } private static string? ValidateSpecies(CharacterDraft draft) { if (draft.IsHybrid) { if (string.IsNullOrEmpty(draft.SireSpeciesId)) return "Pick a sire species."; if (string.IsNullOrEmpty(draft.DamSpeciesId)) return "Pick a dam species."; // Phase B: one species trait + one species detriment per lineage. // A species with an empty detriment list still requires explicit // confirmation — UI shows "(none)" affordance. if (string.IsNullOrEmpty(draft.SireChosenSpeciesTrait)) return "Pick a sire species trait."; if (string.IsNullOrEmpty(draft.DamChosenSpeciesTrait)) return "Pick a dam species trait."; var sireSp = CodexContent.SpeciesById(draft.SireSpeciesId); var damSp = CodexContent.SpeciesById(draft.DamSpeciesId); if (sireSp is not null && sireSp.Detriments.Length > 0 && string.IsNullOrEmpty(draft.SireChosenSpeciesDetriment)) return "Pick a sire species detriment."; if (damSp is not null && damSp.Detriments.Length > 0 && string.IsNullOrEmpty(draft.DamChosenSpeciesDetriment)) return "Pick a dam species detriment."; // Lineage-axis variants: each parent species needs an // explicit lineage pick when applicable. if (sireSp is not null && sireSp.VariantAxis == "lineage" && string.IsNullOrEmpty(draft.SireSpeciesVariant)) return "Pick a sire species lineage."; if (damSp is not null && damSp.VariantAxis == "lineage" && string.IsNullOrEmpty(draft.DamSpeciesVariant)) return "Pick a dam species lineage."; return null; } if (string.IsNullOrEmpty(draft.SpeciesId)) return "Pick a species."; var purebredSp = CodexContent.SpeciesById(draft.SpeciesId); if (purebredSp is not null && purebredSp.VariantAxis == "lineage" && string.IsNullOrEmpty(draft.SpeciesVariant)) return "Pick a species lineage."; return null; } private static string? ValidateSkills(CharacterDraft draft) { var cls = CodexContent.Class(draft.ClassId); int need = cls?.SkillsChoose ?? 0; int got = draft.ChosenSkills.Count; if (need == 0) return null; // class has no skill picks (shouldn't happen, defensive) if (got == need) return null; return $"Pick {need} skill{(need == 1 ? "" : "s")} ({got}/{need})."; } /// /// Index of the lowest step whose validator currently fails, or -1 when /// every step is satisfied. The forward-lock rule is: a step i /// after the current step is locked iff FirstIncomplete < i. /// public static int FirstIncomplete(CharacterDraft draft, int stepCount = 8) { for (int i = 0; i < stepCount; i++) if (Validate(i, draft) is not null) return i; return -1; } }