Files
TheriapolisV3/Theriapolis.Tools/Commands/CharacterRoll.cs
T

192 lines
8.7 KiB
C#
Raw Normal View History

using Theriapolis.Core.Data;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Tools.Commands;
/// <summary>
/// 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)
/// </summary>
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<string>();
var order = new List<string>();
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<string, int>();
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}]" : "")}");
}
}