using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace MazeGen
{
///
/// Resolves a battle between a group of agents and a group of monsters, all in one room.
///
/// Now includes grid-based positioning and movement. Combatants must move into range
/// before they can attack. Movement is determined by Speed stat (Speed/2 = squares per turn).
///
/// 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;
private const int CombatGridWidth = 11;
private const int CombatGridHeight = 7;
///
/// 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).");
// Initialize combat grid and place combatants
CombatGrid grid = InitializeGrid(combatants);
// 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} --");
// Select actions for all combatants this round
var actions = SelectActions(order, combatants, grid);
// Execute actions in order (movements first, then attacks)
ExecuteActions(actions, order, grid, result, round, combatants);
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;
if (result.Outcome == BattleOutcome.AgentsVictory)
{
LootMonsters(combatants);
}
Emit(result, CombatEventKind.FightEnd, round, -1, -1, 0f, result.Summary);
return result;
}
///
/// Initialize the combat grid and place all combatants on it.
/// Monsters start fresh (full health each room), but agents retain damage between fights.
/// Resets grid positions to ensure clean placement each fight.
///
private static CombatGrid InitializeGrid(IReadOnlyList combatants)
{
// Reset grid positions for all combatants (they persist between fights)
foreach (var combatant in combatants)
{
combatant.GridPosition = Vector2Int.zero;
}
// Monsters always start at full health (they're spawned per-room)
// Agents keep their damage from previous fights (only healed by potions)
foreach (var combatant in combatants)
{
if (!combatant.IsAgent) // Only monsters start fresh
{
combatant.Sheet.Stats.RestoreHealth();
}
}
var grid = new CombatGrid(new Vector2Int(CombatGridWidth, CombatGridHeight), 1f);
grid.PlaceAllCombatants(combatants);
// Snapshot the starting position for playback - GridPosition keeps changing as the
// fight resolves, so this is what BattleArenaView uses to place sprites correctly
// from the first frame instead of wherever the fight ended up.
foreach (var combatant in combatants)
{
combatant.StartGridPosition = combatant.GridPosition;
}
return grid;
}
///
/// Select actions for all alive combatants this turn.
/// Agents try health potions first if low on health, then decide between movement and attack.
/// Monsters always attack or move closer.
///
private static List SelectActions(
List order,
IReadOnlyList allCombatants,
CombatGrid grid)
{
var actions = new List();
foreach (var actor in order)
{
if (!actor.IsAlive) continue;
CombatAction action = null;
// Agent health potion logic
if (actor.IsAgent && actor.Agent.CharacterSheet.Stats.CurrentHealth <
actor.Agent.CharacterSheet.Stats.MaxHealth * 0.2f)
{
Consumable healthPotion = actor.Agent.CharacterSheet.GetHealthPotionFromInventory();
if (healthPotion != null)
{
action = new CombatAction(actor.Id, ActionType.UseItem);
action.TargetItem = healthPotion;
actions.Add(action);
continue;
}
}
// Pick a target
Combatant target = PickTarget(allCombatants, actor);
if (target == null)
{
action = new CombatAction(actor.Id, ActionType.Pass);
actions.Add(action);
continue;
}
// Get equipped weapon (or default to unarmed melee range 1)
Weapon weapon = actor.Sheet.EquippedWeapon;
int attackRange = weapon != null ? weapon.Range : 1; // Fists = range 1, adjacent only
// Check if in attack range
bool inRange = false;
if (weapon != null)
{
inRange = MovementValidator.CanAttack(actor, target, weapon);
}
else
{
// Unarmed: must be adjacent (distance = 1)
inRange = actor.IsAdjacentTo(target);
}
if (inRange)
{
action = new CombatAction(actor.Id, target);
actions.Add(action);
}
else
{
// Out of range, try to move closer
Vector2Int nextPos = MovementValidator.GetNextPositionTowards(
actor.GridPosition, target.GridPosition);
if (MovementValidator.CanMoveTo(actor, nextPos, grid))
{
action = new CombatAction(actor.Id, nextPos);
actions.Add(action);
}
else
{
// Can't move, just pass
action = new CombatAction(actor.Id, ActionType.Pass);
actions.Add(action);
}
}
}
return actions;
}
///
/// Execute all actions for this round in order.
///
private static void ExecuteActions(
List actions,
List turnOrder,
CombatGrid grid,
BattleResult result,
int round,
IReadOnlyList allCombatants)
{
foreach (var action in actions)
{
// Find the actor
Combatant actor = null;
foreach (var c in turnOrder)
{
if (c.Id == action.ActorId)
{
actor = c;
break;
}
}
if (actor == null || !actor.IsAlive) continue;
switch (action.ActionType)
{
case ActionType.Move:
ExecuteMovement(actor, action.TargetPosition, grid, result, round);
break;
case ActionType.Attack:
ExecuteAttack(actor, action.TargetCombatant, grid, result, round);
break;
case ActionType.UseItem:
ExecuteUseItem(actor, action.TargetItem, result, round);
break;
case ActionType.Defend:
// TODO: Implement defend action (damage reduction this turn)
break;
case ActionType.Pass:
default:
// Do nothing
break;
}
// Check if fight is over after each action
if (!HasAlive(allCombatants, Combatant.TeamAgents) ||
!HasAlive(allCombatants, Combatant.TeamMonsters))
{
break;
}
}
}
///
/// Execute a movement action for an actor.
///
private static void ExecuteMovement(
Combatant actor,
Vector2Int destination,
CombatGrid grid,
BattleResult result,
int round)
{
Vector2Int startPos = actor.GridPosition;
if (!grid.TryMoveCombatant(actor, destination))
{
Emit(result, CombatEventKind.TargetUnreachable, round, actor.Id, -1, 0f,
$"{actor.Name} could not reach {destination}.");
return;
}
int distance = MovementValidator.ManhattanDistance(startPos, destination);
Emit(result, CombatEventKind.MovementStart, round, actor.Id, -1, 0f,
$"{actor.Name} moves from {startPos} to {destination}.");
Emit(result, CombatEventKind.MovementComplete, round, actor.Id, -1, distance,
$"{actor.Name} has moved {distance} square(s).");
var moveEvent = result.Events[result.Events.Count - 1];
moveEvent.SourcePosition = startPos;
moveEvent.TargetPosition = destination;
moveEvent.MovementDistance = distance;
}
///
/// Execute an attack action, with range validation.
/// Supports both armed and unarmed (fist) attacks.
///
private static void ExecuteAttack(
Combatant actor,
Combatant target,
CombatGrid grid,
BattleResult result,
int round)
{
if (target == null || !target.IsAlive)
return;
Weapon weapon = actor.Sheet.EquippedWeapon;
bool isUnarmed = weapon == null;
// Validate range
bool canAttack = false;
if (isUnarmed)
{
// Unarmed: must be adjacent
canAttack = actor.IsAdjacentTo(target);
if (!canAttack)
{
Emit(result, CombatEventKind.TooFarForMelee, round, actor.Id, target.Id, 0f,
$"{actor.Name} is not adjacent to {target.Name} and cannot use fists.");
return;
}
}
else
{
// Armed: use weapon's range
canAttack = MovementValidator.CanAttack(actor, target, weapon);
if (!canAttack)
{
if (weapon.WeaponType == WeaponType.Melee)
{
Emit(result, CombatEventKind.TooFarForMelee, round, actor.Id, target.Id, 0f,
$"{actor.Name} is not adjacent to {target.Name} and cannot use {weapon.ItemName}.");
}
else
{
Emit(result, CombatEventKind.OutOfRange, round, actor.Id, target.Id, 0f,
$"{actor.Name}'s {weapon.ItemName} cannot reach {target.Name} (range: {weapon.Range}, distance: {actor.DistanceTo(target)}).");
}
return;
}
}
// Attack is valid, execute it
TakeSwing(result, round, actor, target);
}
///
/// Execute using a consumable item.
///
private static void ExecuteUseItem(
Combatant actor,
Consumable item,
BattleResult result,
int round)
{
if (item == null) return;
if (item is Consumable consumable)
{
actor.Sheet.Stats.Heal(consumable.HealthBonus);
Emit(result, CombatEventKind.HealthPotionUsed, round, actor.Id, -1,
consumable.HealthBonus,
$"{actor.Name} used {item.ItemName} to heal {consumable.HealthBonus} health.");
}
}
///
/// 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;
private static void LootMonsters(IReadOnlyList combatants)
{
List aliveAgentCombatants = combatants.Where(c => c.IsAgent && c.IsAlive).ToList();
List deadMonsters = combatants.Where(c => !c.IsAgent && !c.IsAlive).ToList();
aliveAgentCombatants = aliveAgentCombatants.OrderBy(x => Random.value).ToList(); // Randomize the order of agents to distribute loot fairly
for (int i = 0; i < deadMonsters.Count; i++)
{
Combatant deadMonster = deadMonsters[i];
Combatant lootingAgent = aliveAgentCombatants[i % aliveAgentCombatants.Count];
Item loot = deadMonster.Monster.GetRandomLoot(); // Now uses the monster instance's remaining loot pool
if (loot != null)
{
lootingAgent.Agent.CharacterSheet.AddToInventory(loot);
Debug.Log($"{lootingAgent.Name} looted {loot.ItemName} from {deadMonster.Name}.");
}
}
}
}
}