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;
///
/// 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.
///
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;
}
/// Simple one-attack-per-turn AI: move toward the nearest hostile, attack when in reach.
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();
var order = new List();
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();
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);
}
}