using Theriapolis.Core.Data; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Stats; namespace Theriapolis.Tools.Commands; /// /// Headless character builder. Useful for verifying CharacterBuilder /// determinism, dumping a representative stat block, or scripted balance /// sweeps in CI. /// /// Usage: /// dotnet run --project Theriapolis.Tools -- character-roll \ /// --seed 12345 \ /// --clade canidae --species wolf \ /// --class fangsworn --background pack_raised \ /// --name "Grev" \ /// [--data-dir ./Content/Data] \ /// [--roll] (use 4d6-drop-lowest instead of Standard Array) /// [--ms-override 1000] (fix the rolling seed for reproducibility) /// [--level N] (Phase 7 M0 — apply N-1 level-ups deterministically; /// picks the first subclass at L3 and assigns ASI to /// CON each ASI level for predictable balance testing) /// public static class CharacterRoll { public static int Run(string[] args) { ulong seed = 12345UL; string dataDir = "./Content/Data"; string cladeId = "canidae"; string speciesId = "wolf"; string classId = "fangsworn"; string bgId = "pack_raised"; string name = "Wanderer"; bool roll = false; ulong msOverride = 0; bool msOverrideSet = false; int targetLevel = 1; for (int i = 0; i < args.Length; i++) { switch (args[i]) { case "--seed": seed = ulong.Parse(args[++i]); break; case "--data-dir": dataDir = args[++i]; break; case "--clade": cladeId = args[++i]; break; case "--species": speciesId= args[++i]; break; case "--class": classId = args[++i]; break; case "--background": bgId = args[++i]; break; case "--name": name = args[++i]; break; case "--roll": roll = true; break; case "--ms-override": msOverride = ulong.Parse(args[++i]); msOverrideSet = true; break; case "--level": targetLevel = int.Parse(args[++i]); break; } } if (targetLevel < 1 || targetLevel > Theriapolis.Core.C.CHARACTER_LEVEL_MAX) { Console.Error.WriteLine($"--level must be in [1, {Theriapolis.Core.C.CHARACTER_LEVEL_MAX}]"); return 1; } var loader = new ContentLoader(dataDir); var content = new ContentResolver(loader); if (!content.Clades.TryGetValue(cladeId, out var clade)) { Console.Error.WriteLine($"Unknown clade: {cladeId}"); return 1; } if (!content.Species.TryGetValue(speciesId, out var species)) { Console.Error.WriteLine($"Unknown species: {speciesId}"); return 1; } if (!content.Classes.TryGetValue(classId, out var classDef)) { Console.Error.WriteLine($"Unknown class: {classId}"); return 1; } if (!content.Backgrounds.TryGetValue(bgId, out var bg)) { Console.Error.WriteLine($"Unknown background: {bgId}"); return 1; } // Stats AbilityScores baseAbilities; if (roll) { ulong msArg = msOverrideSet ? msOverride : (ulong)Environment.TickCount64; baseAbilities = CharacterBuilder.RollAbilityScores(seed, msArg); } else { // Standard Array assigned by class priority (matches CharacterCreationScreen default). int[] values = (int[])AbilityScores.StandardArray.Clone(); Array.Sort(values, (a, b) => b - a); baseAbilities = AssignByClassPriority(classDef, values); } var b = new CharacterBuilder { Clade = clade, Species = species, ClassDef = classDef, Background = bg, BaseAbilities = baseAbilities, Name = name, }; // Auto-pick first N skills int n = classDef.SkillsChoose; foreach (var raw in classDef.SkillOptions) { if (b.ChosenClassSkills.Count >= n) break; try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { } } if (!b.Validate(out var error)) { Console.Error.WriteLine($"Validation failed: {error}"); return 1; } var c = b.Build(content.Items); // Phase 7 M0 — apply N-1 level-ups deterministically. The seed for // each level mirrors the Phase 6.5 contract: // levelUpSeed = worldSeed ^ msOverride ^ RNG_LEVELUP ^ targetLevel // We default subclass to the class's first declared subclass and // ASIs to a +2-CON bias for predictable HP scaling — this is balance- // sweep mode, not playthrough mode. Players in-game pick their own. ulong msForLevelup = msOverrideSet ? msOverride : 0UL; for (int lv = 2; lv <= targetLevel; lv++) { ulong levelSeed = seed ^ msForLevelup ^ Theriapolis.Core.C.RNG_LEVELUP ^ (ulong)lv; var result = Theriapolis.Core.Rules.Character.LevelUpFlow.Compute( c, lv, levelSeed, takeAverage: true, subclasses: content.Subclasses); var choices = new Theriapolis.Core.Rules.Character.LevelUpChoices { TakeAverageHp = true, SubclassId = result.GrantsSubclassChoice && classDef.SubclassIds.Length > 0 ? classDef.SubclassIds[0] : null, }; if (result.GrantsAsiChoice) { choices.AsiAdjustments[Theriapolis.Core.Rules.Stats.AbilityId.CON] = 2; } c.ApplyLevelUp(result, choices); } PrintCharacter(c, name); return 0; } private static AbilityScores AssignByClassPriority(ClassDef classDef, int[] sortedDescValues) { var primary = classDef.PrimaryAbility ?? Array.Empty(); var order = new List(); foreach (var p in primary) order.Add(p.ToUpperInvariant()); foreach (var a in new[] { "CON", "DEX", "STR", "WIS", "INT", "CHA" }) if (!order.Contains(a)) order.Add(a); var assigned = new Dictionary(); for (int i = 0; i < 6; i++) assigned[order[i]] = sortedDescValues[i]; return new AbilityScores( assigned["STR"], assigned["DEX"], assigned["CON"], assigned["INT"], assigned["WIS"], assigned["CHA"]); } private static void PrintCharacter(Character c, string name) { Console.WriteLine(); Console.WriteLine($"=== {name} ==="); Console.WriteLine($"Clade: {c.Clade.Name} ({c.Clade.Kind})"); Console.WriteLine($"Species: {c.Species.Name} size: {c.Species.Size} speed: {c.Species.BaseSpeedFt} ft."); Console.WriteLine($"Class: {c.ClassDef.Name} hit die: d{c.ClassDef.HitDie} level: {c.Level}"); Console.WriteLine($"Background: {c.Background.Name}"); Console.WriteLine(); Console.WriteLine("Ability scores (post clade + species mods):"); Console.WriteLine($" STR {c.Abilities.STR} DEX {c.Abilities.DEX} CON {c.Abilities.CON}"); Console.WriteLine($" INT {c.Abilities.INT} WIS {c.Abilities.WIS} CHA {c.Abilities.CHA}"); Console.WriteLine(); Console.WriteLine($"HP: {c.CurrentHp}/{c.MaxHp}"); Console.WriteLine($"AC: {Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(c)}"); Console.WriteLine($"Speed: {Theriapolis.Core.Rules.Stats.DerivedStats.SpeedFt(c)} ft."); Console.WriteLine($"Carry: {c.Inventory.TotalWeightLb:F1} / {Theriapolis.Core.Rules.Stats.DerivedStats.CarryCapacityLb(c):F1} lb ({Theriapolis.Core.Rules.Stats.DerivedStats.Encumbrance(c)})"); Console.WriteLine($"Initiative:{(Theriapolis.Core.Rules.Stats.DerivedStats.Initiative(c) >= 0 ? "+" : "")}{Theriapolis.Core.Rules.Stats.DerivedStats.Initiative(c)}"); Console.WriteLine($"Prof: +{c.ProficiencyBonus}"); Console.WriteLine(); Console.WriteLine($"Skill proficiencies ({c.SkillProficiencies.Count}):"); foreach (var s in c.SkillProficiencies.OrderBy(s => s.ToString())) Console.WriteLine($" - {s} ({s.Ability()})"); Console.WriteLine(); Console.WriteLine($"Inventory ({c.Inventory.Items.Count} stacks, {c.Inventory.TotalWeightLb:F1} lb):"); foreach (var i in c.Inventory.Items) Console.WriteLine($" - {i.Def.Name}{(i.Qty > 1 ? $" ×{i.Qty}" : "")}{(i.EquippedAt is { } slot ? $" [{slot}]" : "")}"); } }