using System.Collections.Generic;
using UnityEngine;
namespace MazeGen
{
///
/// Resolves a battle between a group of agents and a group of monsters, all in one room.
///
/// Runs to completion synchronously and returns the whole thing as an ordered
/// list (see ). Nothing about this is
/// frame-based or animated - "watching" a fight is purely a replay of the list this produces,
/// which is what guarantees the watched and log-only outcomes are identical.
///
/// Combatant health is mutated in place on the real character sheets, so damage and deaths
/// persist into the maze afterwards.
///
public static class CombatResolver
{
// Tuning. Deliberately simple and legible - swap in a richer model (abilities, damage
// types, status effects) without touching the director or the arena, since both only
// ever consume the event list.
private const float BaseHitChance = 0.75f;
private const float HitChancePerSpeedPoint = 0.01f;
private const float MinHitChance = 0.35f;
private const float MaxHitChance = 0.95f;
private const float DamageVariance = 0.15f;
private const float DefenseMitigationPerPoint = 0.1f;
private const float MaxMitigation = 0.9f;
///
/// Fights it out until one side is wiped or is exhausted
/// (which yields a stalemate - both sides disengage rather than looping forever).
///
public static BattleResult Resolve(IReadOnlyList combatants, int maxRounds = 40)
{
var result = new BattleResult { Combatants = combatants };
int agentCount = CountAlive(combatants, Combatant.TeamAgents);
int monsterCount = CountAlive(combatants, Combatant.TeamMonsters);
Emit(result, CombatEventKind.FightStart, 0, -1, -1, 0f,
$"Fight begins: {agentCount} agent(s) vs {monsterCount} monster(s).");
// Initiative is rolled once for the whole battle (classic fixed turn order) so the
// arena can lay combatants out in a stable order that matches the log.
var order = BuildTurnOrder(combatants);
int round = 0;
while (round < maxRounds && HasAlive(combatants, Combatant.TeamAgents) && HasAlive(combatants, Combatant.TeamMonsters))
{
round++;
Emit(result, CombatEventKind.RoundStart, round, -1, -1, 0f, $"-- Round {round} --");
foreach (var actor in order)
{
if (!actor.IsAlive) continue;
Combatant target = PickTarget(combatants, actor);
if (target == null) break; // no enemies left
TakeSwing(result, round, actor, target);
if (!HasAlive(combatants, Combatant.TeamAgents) || !HasAlive(combatants, Combatant.TeamMonsters))
break;
}
}
result.Rounds = round;
Tally(result, combatants);
bool agentsLeft = HasAlive(combatants, Combatant.TeamAgents);
bool monstersLeft = HasAlive(combatants, Combatant.TeamMonsters);
result.Outcome = !monstersLeft && agentsLeft ? BattleOutcome.AgentsVictory
: !agentsLeft && monstersLeft ? BattleOutcome.MonstersVictory
: !agentsLeft && !monstersLeft ? BattleOutcome.MonstersVictory // mutual wipe counts as a loss for the agents
: BattleOutcome.Stalemate;
Emit(result, CombatEventKind.FightEnd, round, -1, -1, 0f, result.Summary);
return result;
}
///
/// Single attack: roll to hit, then damage attacker's effective attack against the
/// target's effective defense.
///
private static void TakeSwing(BattleResult result, int round, Combatant actor, Combatant target)
{
float hitChance = Mathf.Clamp(
BaseHitChance + HitChancePerSpeedPoint * (actor.Sheet.GetEffectiveSpeed() - target.Sheet.GetEffectiveSpeed()),
MinHitChance, MaxHitChance);
if (Random.value > hitChance)
{
Emit(result, CombatEventKind.Miss, round, actor.Id, target.Id, 0f,
$"{actor.Name} swings at {target.Name} and misses ({hitChance:P0} to hit).");
return;
}
// Note: this uses the target's EFFECTIVE defense (base + equipment/buffs), unlike
// CharacterStats.CalculateDamageReduction which only ever sees base defense.
float mitigation = Mathf.Clamp(target.Sheet.GetEffectiveDefense() * DefenseMitigationPerPoint, 0f, MaxMitigation);
float roll = Random.Range(1f - DamageVariance, 1f + DamageVariance);
float damage = Mathf.Max(1f, actor.Sheet.GetEffectiveAttack() * roll * (1f - mitigation));
target.Sheet.Stats.TakeDamage(damage);
Emit(result, CombatEventKind.Hit, round, actor.Id, target.Id, damage,
$"{actor.Name} hits {target.Name} for {damage:0.#} ({target.Sheet.Stats.CurrentHealth:0.#}/{target.MaxHealth:0.#} hp left).");
if (!target.IsAlive)
{
Emit(result, CombatEventKind.Death, round, actor.Id, target.Id, 0f,
$"{target.Name} is killed by {actor.Name}.");
}
}
/// Fastest first; ties broken randomly so identical monsters don't always act in spawn order.
private static List BuildTurnOrder(IReadOnlyList combatants)
{
var order = new List(combatants);
var initiative = new Dictionary(order.Count);
foreach (var c in order)
initiative[c.Id] = c.Sheet.GetEffectiveSpeed() + Random.Range(0f, 4f);
order.Sort((a, b) => initiative[b.Id].CompareTo(initiative[a.Id]));
return order;
}
/// Picks a random living enemy. Random rather than focus-fire so groups spread damage.
private static Combatant PickTarget(IReadOnlyList combatants, Combatant actor)
{
var candidates = new List();
foreach (var c in combatants)
if (c.Team != actor.Team && c.IsAlive)
candidates.Add(c);
return candidates.Count == 0 ? null : candidates[Random.Range(0, candidates.Count)];
}
private static void Tally(BattleResult result, IReadOnlyList combatants)
{
foreach (var c in combatants)
{
if (c.IsAgent)
{
if (c.IsAlive) result.SurvivingAgents.Add(c);
else result.FallenAgents.Add(c);
}
else if (!c.IsAlive)
{
result.FallenMonsters.Add(c);
}
}
}
private static void Emit(BattleResult result, CombatEventKind kind, int round, int actorId, int targetId, float amount, string text)
{
result.Events.Add(new CombatEvent
{
Kind = kind,
Round = round,
ActorId = actorId,
TargetId = targetId,
Amount = amount,
Text = text
});
}
private static int CountAlive(IReadOnlyList combatants, int team)
{
int count = 0;
foreach (var c in combatants)
if (c.Team == team && c.IsAlive) count++;
return count;
}
private static bool HasAlive(IReadOnlyList combatants, int team) => CountAlive(combatants, team) > 0;
}
}