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); } }