Files
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

192 lines
8.7 KiB
C#
Raw Permalink 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 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}]" : "")}");
}
}