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>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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}]" : "")}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user