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