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,239 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Tools.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Headless combat scenario runner. Builds two combatants, places them on
|
||||
/// an empty arena, and runs a simple "close distance, then attack" AI loop
|
||||
/// until one side falls or the round cap is hit. Prints the full encounter
|
||||
/// log so test scenarios and balance sweeps can grep the output.
|
||||
///
|
||||
/// Usage:
|
||||
/// dotnet run --project Theriapolis.Tools -- combat-duel \
|
||||
/// --a brigand_footpad --b wolf --seed 42 [--rounds 20] [--data-dir ./Content/Data]
|
||||
///
|
||||
/// Combatant specs:
|
||||
/// - An NPC template id (e.g. "brigand_footpad", "wolf", "bear_brown")
|
||||
/// - A character spec "char:CLADE:SPECIES:CLASS:BACKGROUND" (e.g.
|
||||
/// "char:canidae:wolf:fangsworn:pack_raised") — uses Standard Array
|
||||
/// stats and the class's starting kit.
|
||||
/// </summary>
|
||||
public static class CombatDuel
|
||||
{
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
ulong seed = 42UL;
|
||||
string dataDir = "./Content/Data";
|
||||
string specA = "brigand_footpad";
|
||||
string specB = "wolf";
|
||||
int maxRounds = 20;
|
||||
|
||||
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 "--a": specA = args[++i]; break;
|
||||
case "--b": specB = args[++i]; break;
|
||||
case "--rounds": maxRounds = int.Parse(args[++i]); break;
|
||||
}
|
||||
}
|
||||
|
||||
var loader = new ContentLoader(dataDir);
|
||||
var content = new ContentResolver(loader);
|
||||
|
||||
Combatant a, b;
|
||||
try
|
||||
{
|
||||
a = BuildCombatant(specA, content, id: 1, position: new Vec2(0, 0),
|
||||
allegiance: Allegiance.Player);
|
||||
b = BuildCombatant(specB, content, id: 2, position: new Vec2(6, 0),
|
||||
allegiance: Allegiance.Hostile);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
System.Console.Error.WriteLine($"Failed to build combatants: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var enc = new Encounter(seed, encounterId: 1, new[] { a, b });
|
||||
|
||||
System.Console.WriteLine($"=== combat-duel: {a.Name} vs {b.Name} ===");
|
||||
System.Console.WriteLine($"Seed: 0x{seed:X} encounterSeed: 0x{enc.EncounterSeed:X}");
|
||||
System.Console.WriteLine($"Initial positions: {a.Name} ({a.Position.X},{a.Position.Y}) HP {a.CurrentHp} AC {a.ArmorClass}; " +
|
||||
$"{b.Name} ({b.Position.X},{b.Position.Y}) HP {b.CurrentHp} AC {b.ArmorClass}");
|
||||
System.Console.WriteLine();
|
||||
|
||||
int turnsTaken = 0;
|
||||
// Hard safety cap: at most 4 actions per combatant per round.
|
||||
int maxTurns = maxRounds * (a.AttackOptions.Count + b.AttackOptions.Count) * 4 + 4;
|
||||
while (!enc.IsOver && enc.RoundNumber <= maxRounds && turnsTaken < maxTurns)
|
||||
{
|
||||
turnsTaken++;
|
||||
DriveTurn(enc);
|
||||
enc.EndTurn();
|
||||
}
|
||||
|
||||
if (enc.RoundNumber > maxRounds && !enc.IsOver)
|
||||
System.Console.WriteLine($"--- Round cap reached ({maxRounds} rounds). ---");
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("--- Combat log ---");
|
||||
foreach (var entry in enc.Log)
|
||||
System.Console.WriteLine($"R{entry.Round} T{entry.Turn} [{entry.Type}] {entry.Message}");
|
||||
System.Console.WriteLine();
|
||||
|
||||
var aliveA = a.IsAlive && !a.IsDown;
|
||||
var aliveB = b.IsAlive && !b.IsDown;
|
||||
System.Console.WriteLine($"Final: {a.Name} HP {a.CurrentHp}/{a.MaxHp} {(aliveA ? "alive" : "down")}; " +
|
||||
$"{b.Name} HP {b.CurrentHp}/{b.MaxHp} {(aliveB ? "alive" : "down")}");
|
||||
System.Console.WriteLine($"Total dice rolled: {enc.RollCount}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Simple one-attack-per-turn AI: move toward the nearest hostile, attack when in reach.</summary>
|
||||
private static void DriveTurn(Encounter enc)
|
||||
{
|
||||
var actor = enc.CurrentActor;
|
||||
if (!actor.IsAlive || actor.IsDown) return;
|
||||
|
||||
var target = FindHostile(enc, actor);
|
||||
if (target is null) return;
|
||||
var attack = actor.AttackOptions[0];
|
||||
|
||||
// Movement budget — convert ft. to tiles. 5 ft. = 1 tile (d20 standard).
|
||||
int tilesAvailable = enc.CurrentTurn.RemainingMovementFt / 5;
|
||||
while (!ReachAndCover.IsInReach(actor, target, attack) && tilesAvailable > 0)
|
||||
{
|
||||
var next = ReachAndCover.StepToward(actor.Position, target.Position);
|
||||
if (next.X == actor.Position.X && next.Y == actor.Position.Y) break;
|
||||
actor.Position = next;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Move,
|
||||
$"{actor.Name} moves to ({next.X},{next.Y}).");
|
||||
tilesAvailable--;
|
||||
}
|
||||
// Charge the consumed movement back to the turn budget.
|
||||
int consumed = (enc.CurrentTurn.RemainingMovementFt / 5) - tilesAvailable;
|
||||
enc.CurrentTurn.ConsumeMovement(consumed * 5);
|
||||
|
||||
if (!ReachAndCover.IsInReach(actor, target, attack)) return;
|
||||
|
||||
Resolver.AttemptAttack(enc, actor, target, attack);
|
||||
enc.CurrentTurn.ConsumeAction();
|
||||
}
|
||||
|
||||
private static Combatant? FindHostile(Encounter enc, Combatant actor)
|
||||
{
|
||||
// Hostile = different allegiance side. Player + Allied are friends; Hostile vs Player.
|
||||
Combatant? best = null;
|
||||
int bestDist = int.MaxValue;
|
||||
foreach (var c in enc.Participants)
|
||||
{
|
||||
if (c.Id == actor.Id) continue;
|
||||
if (!c.IsAlive || c.IsDown) continue;
|
||||
if (!IsHostileTo(actor.Allegiance, c.Allegiance)) continue;
|
||||
int d = ReachAndCover.EdgeToEdgeChebyshev(actor, c);
|
||||
if (d < bestDist) { best = c; bestDist = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static bool IsHostileTo(Allegiance a, Allegiance b)
|
||||
{
|
||||
bool aIsPlayerSide = a == Allegiance.Player || a == Allegiance.Allied;
|
||||
bool bIsPlayerSide = b == Allegiance.Player || b == Allegiance.Allied;
|
||||
if (aIsPlayerSide && b == Allegiance.Hostile) return true;
|
||||
if (bIsPlayerSide && a == Allegiance.Hostile) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Combatant BuildCombatant(string spec, ContentResolver content, int id, Vec2 position, Allegiance allegiance)
|
||||
{
|
||||
if (spec.StartsWith("char:", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = spec.Split(':');
|
||||
if (parts.Length != 5)
|
||||
throw new System.ArgumentException($"Character spec '{spec}' must be char:clade:species:class:background");
|
||||
string cladeId = parts[1];
|
||||
string speciesId = parts[2];
|
||||
string classId = parts[3];
|
||||
string bgId = parts[4];
|
||||
var character = BuildLevel1Character(content, cladeId, speciesId, classId, bgId);
|
||||
string name = character.Species.Name + " " + character.ClassDef.Name;
|
||||
return Combatant.FromCharacter(character, id, name, position, allegiance);
|
||||
}
|
||||
|
||||
// Otherwise treat as an NPC template id.
|
||||
if (!content.Npcs.Templates.Any(t => string.Equals(t.Id, spec, System.StringComparison.OrdinalIgnoreCase)))
|
||||
throw new System.ArgumentException($"Unknown NPC template id: '{spec}'");
|
||||
var def = content.Npcs.Templates.First(t => string.Equals(t.Id, spec, System.StringComparison.OrdinalIgnoreCase));
|
||||
var combatant = Combatant.FromNpcTemplate(def, id, position);
|
||||
// Allow caller to override allegiance (e.g. force a brigand to fight a wolf).
|
||||
// A new Combatant doesn't expose Allegiance setter; build a fresh one with the override.
|
||||
if (combatant.Allegiance != allegiance)
|
||||
{
|
||||
// Cheap rebuild: clone the template's combatant with overridden allegiance.
|
||||
combatant = CloneWithAllegiance(combatant, allegiance);
|
||||
}
|
||||
return combatant;
|
||||
}
|
||||
|
||||
private static Combatant CloneWithAllegiance(Combatant c, Allegiance newAllegiance)
|
||||
{
|
||||
// Combatant has no copy-with API; for the duel tool we just rebuild from
|
||||
// the source template with the overridden allegiance baked in. The template
|
||||
// is the only path that produces NPC combatants in this command.
|
||||
var def = c.SourceTemplate!;
|
||||
// Fake the template's default_allegiance with a mutated copy (records support `with`).
|
||||
var swapped = def with { DefaultAllegiance = newAllegiance.ToString().ToLowerInvariant() };
|
||||
return Combatant.FromNpcTemplate(swapped, c.Id, c.Position);
|
||||
}
|
||||
|
||||
private static Character BuildLevel1Character(
|
||||
ContentResolver content, string cladeId, string speciesId, string classId, string bgId)
|
||||
{
|
||||
if (!content.Clades.TryGetValue(cladeId, out var clade))
|
||||
throw new System.ArgumentException($"Unknown clade: {cladeId}");
|
||||
if (!content.Species.TryGetValue(speciesId, out var species))
|
||||
throw new System.ArgumentException($"Unknown species: {speciesId}");
|
||||
if (!content.Classes.TryGetValue(classId, out var classDef))
|
||||
throw new System.ArgumentException($"Unknown class: {classId}");
|
||||
if (!content.Backgrounds.TryGetValue(bgId, out var bg))
|
||||
throw new System.ArgumentException($"Unknown background: {bgId}");
|
||||
|
||||
// Standard array assigned by class priority.
|
||||
int[] vals = (int[])AbilityScores.StandardArray.Clone();
|
||||
System.Array.Sort(vals, (a, b) => b - a);
|
||||
var primary = classDef.PrimaryAbility ?? System.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]] = vals[i];
|
||||
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
Clade = clade,
|
||||
Species = species,
|
||||
ClassDef = classDef,
|
||||
Background = bg,
|
||||
BaseAbilities = new AbilityScores(
|
||||
assigned["STR"], assigned["DEX"], assigned["CON"],
|
||||
assigned["INT"], assigned["WIS"], assigned["CHA"]),
|
||||
Name = $"{species.Name} {classDef.Name}",
|
||||
};
|
||||
int n = classDef.SkillsChoose;
|
||||
foreach (var raw in classDef.SkillOptions)
|
||||
{
|
||||
if (b.ChosenClassSkills.Count >= n) break;
|
||||
try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
|
||||
}
|
||||
return b.Build(content.Items);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user