using System; using System.Collections.Generic; using System.Linq; using UnityEngine; namespace MazeGen { /// /// Drives one agent's autonomous exploration: waits out a (randomized) initial delay so /// many agents don't all decide in lockstep, then ticks a steady decision interval - /// each time it fires, the agent deals with whatever is in its current room (fight it, /// join a fight already forming, or sneak past) and otherwise picks an exit and moves. /// Stops deciding once the agent reaches an exit room, dies, or the level ends. /// /// Agents can "peek" into adjacent rooms before entering to see if there are monsters. /// Based on the perceived threat level, they may choose a different exit rather than fight. /// /// Every decision this class makes is narrated into the agent's , /// which is what the HUD shows while an agent is being followed. Adding a decision here /// without a matching Think() call makes the agent's behaviour unexplainable to the observer, /// so keep them together. /// /// All timing runs off , never Time.deltaTime directly, so a /// paused simulation genuinely freezes every agent. /// public class AutonomousAgentBrain : MonoBehaviour { /// Peek observations about the same room are only narrated this many times, to keep the thought log readable. private const int MaxNarratedPeeksPerRoom = 2; private MazeAgentController _controller; private MazeGenerator _generator; private CombatDirector _director; private float _decisionInterval; private float _exploreChance; private float _retreatThreshold = 1.5f; // CR above this = don't pick that fight private Func _isLevelEnded; private float _timer; private readonly Dictionary _peekCount = new Dictionary(); // narration throttle, per room id public void Initialize(MazeAgentController controller, MazeGenerator generator, CombatDirector director, float initialDelay, float decisionInterval, float exploreChance, float retreatThreshold, Func isLevelEnded) { _controller = controller; _generator = generator; _director = director; _decisionInterval = Mathf.Max(0.05f, decisionInterval); _exploreChance = Mathf.Clamp01(exploreChance); _retreatThreshold = Mathf.Max(0, retreatThreshold); _isLevelEnded = isLevelEnded; _timer = Mathf.Max(0f, initialDelay); } private void Update() { MazeAgent agent = _controller?.Agent; if (agent == null || agent.IsFinished || agent.IsDead) return; if (_isLevelEnded != null && _isLevelEnded()) return; // Committed to a fight: the CombatDirector owns this agent until it resolves. if (agent.IsEngaged) return; _timer -= SimulationClock.DeltaTime; if (_timer > 0f) return; _timer = _decisionInterval; if (HandleRoomHostiles(agent)) return; MoveOn(agent); } private void Think(ThoughtKind kind, string text) => _controller?.Agent?.Thoughts.Add(kind, text); // ---------------------------------------------------------------- fighting & sneaking /// /// Deals with hostiles in the room the agent is standing in. Returns true if this took /// the agent's turn (it committed to a fight, or spent the tick slipping away). /// private bool HandleRoomHostiles(MazeAgent agent) { if (_director == null) return false; Room room = agent.CurrentRoom; if (room == null) return false; // A fight is already mustering here - pile in for better odds, or try to slip past. PendingFight mustering = _director.FindMusteringFight(room); if (mustering != null && !mustering.Contains(agent)) return HandleArrivalAtFight(agent, mustering); if (room.Encounter == null || !room.Encounter.HasLivingMonsters) return false; int hostiles = room.Encounter.AliveMonsterCount; float threat = room.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet); // Already tried and failed to finish these off once - don't loop on it. if (agent.HasFledFrom(room.Id)) { Think(ThoughtKind.Avoid, $"Still {hostiles} hostile(s) here and I've already failed to beat them - leaving."); return false; // fall through to MoveOn so it actually walks out this tick } if (threat <= _retreatThreshold) { Think(ThoughtKind.Fight, $"Peeked: {hostiles} hostile(s), threat {threat:0.00} vs my limit {_retreatThreshold:0.00} - engaging. Fight starts in {_director.JoinWindowSeconds:0.0}s, hopefully someone joins."); _director.DeclareFight(room, agent, $"sized up {hostiles} hostile(s) at threat {threat:0.00} and attacked"); return true; } // Too dangerous to pick - try to get out without a brawl. Think(ThoughtKind.Avoid, $"Peeked: {hostiles} hostile(s) at threat {threat:0.00}, over my limit {_retreatThreshold:0.00} - trying to slip out instead of fighting."); if (_director.TrySneakOut(agent, out float odds)) { Think(ThoughtKind.Sneak, $"Slipped away unnoticed ({odds:P0} odds)."); return false; // MoveOn carries it out of the room this same tick } Think(ThoughtKind.Sneak, $"Botched the sneak ({odds:P0} odds) - they've seen me, no choice but to fight."); _director.DeclareFight(room, agent, $"was caught trying to sneak out past {hostiles} hostile(s)"); return true; } /// /// The agent walked into a room where a fight is already forming. It can join (raising /// the odds for everyone) or try to sneak by - and a botched sneak forces it in anyway. /// private bool HandleArrivalAtFight(MazeAgent agent, PendingFight fight) { float threat = fight.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet); // The threat is shared: joining N-1 allies makes the same monsters worth risking. float sharedThreat = threat / (fight.Agents.Count + 1); bool wantsToSneak = sharedThreat > _retreatThreshold || agent.HasFledFrom(fight.Room.Id); if (!wantsToSneak) Think(ThoughtKind.Fight, $"Walked into fight #{fight.Id} - {fight.Agents.Count} ally(s) already in, shared threat {sharedThreat:0.00}. Joining."); else Think(ThoughtKind.Sneak, $"Walked into fight #{fight.Id} - shared threat {sharedThreat:0.00} still over my limit. Trying to sneak past it."); switch (_director.ResolveArrival(agent, wantsToSneak)) { case CombatDirector.ArrivalDecision.Joined: case CombatDirector.ArrivalDecision.SneakFailedAndJoined: return true; // engaged now; the director takes it from here case CombatDirector.ArrivalDecision.SneakedPast: // Made it past - keep going straight through into the next room. Think(ThoughtKind.Sneak, "Made it past them - pressing on into the next room."); return false; default: return false; } } // ---------------------------------------------------------------- movement private void MoveOn(MazeAgent agent) { Exit choice = ChooseExit(agent, agent.CurrentRoom); if (choice == null) { Think(ThoughtKind.Move, $"No usable door out of room {agent.CurrentRoom.Id} - waiting."); return; } string heading = CompassName(choice.DirectionDegrees); Room known = choice.ConnectedRoomId.HasValue ? _generator.Graph.Get(choice.ConnectedRoomId.Value) : null; string destination = known != null ? $"room {known.Id}{(agent.HasVisited(known.Id) ? " (been there)" : " (new to me)")}" : "whatever is behind it (never opened)"; if (_controller.TryUseExit(choice)) Think(ThoughtKind.Move, $"Taking the {heading} door into {destination}."); else Think(ThoughtKind.Move, $"The {heading} door out of room {agent.CurrentRoom.Id} went nowhere - picking again next tick."); } /// /// Picks the next exit to take. Strongly prefers an unused exit (fresh, unexplored /// territory); failing that, prefers a used exit into a room this agent personally /// hasn't visited yet (following where another agent has already been); only /// backtracks into a room it's already visited as a last resort. `exploreChance` /// adds some randomness so a "follow" opportunity is occasionally taken even when /// exploring is available, rather than being 100% deterministic. /// /// A few anti-stuck rules keep this from looping forever around a small ring of /// already-explored rooms (e.g. a square formed by a loop-closing connection): /// - The door leading straight back to the room the agent just came from is excluded /// from consideration unless it's the only door available at all, so agents don't /// ping-pong between two rooms. /// - When forced to backtrack, exits that "peek" into a room with its own unexplored /// option beyond it (an unused exit, or one leading somewhere this agent hasn't /// personally seen) are preferred over ones that only lead deeper into fully-picked- /// over territory - this steers the agent back out of a dead loop instead of just /// circling it. /// - Ties are broken toward whichever room this agent has personally visited the /// fewest times, so a small loop gets cycled through rather than bounced around. /// /// Also evaluates monster threats in adjacent rooms - if a room looks too dangerous, /// the agent will retreat (choose a different exit) if possible. /// private Exit ChooseExit(MazeAgent agent, Room room) { Exit reverseExit = FindReverseExit(agent, room); var explore = new List(); var follow = new List(); var backtrack = new List(); foreach (var exit in room.Exits) { if (exit == reverseExit) continue; if (exit.IsBlocked) continue; // Skip exits that are permanently unusable if (!exit.IsUsed) { explore.Add(exit); continue; } bool leadsToVisited = exit.ConnectedRoomId.HasValue && agent.HasVisited(exit.ConnectedRoomId.Value); (leadsToVisited ? backtrack : follow).Add(exit); } // Filter exits by threat level - remove dangerous ones if safer options exist explore = FilterByThreat(explore, agent); follow = FilterByThreat(follow, agent); backtrack = FilterByThreat(backtrack, agent); if (explore.Count > 0 && UnityEngine.Random.value < _exploreChance) return RandomPick(explore); if (follow.Count > 0) return LeastVisitedPick(agent, follow); if (explore.Count > 0) return RandomPick(explore); if (backtrack.Count > 0) return ChooseBacktrack(agent, backtrack); // Excluding the reverse door left nothing - it's genuinely the only way out // (e.g. a single-exit dead end), so take it (even if dangerous). return reverseExit; } /// The door in `room` that leads straight back to wherever the agent's last move came from, if any. private static Exit FindReverseExit(MazeAgent agent, Room room) { int? reverseExitId = agent.LastUsedExit?.ConnectedExitId; if (!reverseExitId.HasValue) return null; return room.Exits.Find(e => e.Id == reverseExitId.Value); } private Exit ChooseBacktrack(MazeAgent agent, List backtrack) { var promising = backtrack.Where(e => LeadsTowardUnexploredOptions(agent, e)).ToList(); return LeastVisitedPick(agent, promising.Count > 0 ? promising : backtrack); } /// /// One-step lookahead: does the room beyond `exit` itself have any door (other than /// the one leading straight back here) that's still unused or leads somewhere this /// agent hasn't personally been? If so, backtracking this way still has somewhere /// useful to go; if not, it just leads deeper into already-picked-over territory. /// private bool LeadsTowardUnexploredOptions(MazeAgent agent, Exit exit) { if (!exit.ConnectedRoomId.HasValue) return false; Room target = _generator.Graph.Get(exit.ConnectedRoomId.Value); if (target == null) return false; int reverseExitId = exit.ConnectedExitId ?? -1; foreach (var candidate in target.Exits) { if (candidate.Id == reverseExitId) continue; if (!candidate.IsUsed) return true; if (candidate.ConnectedRoomId.HasValue && !agent.HasVisited(candidate.ConnectedRoomId.Value)) return true; } return false; } private static Exit LeastVisitedPick(MazeAgent agent, List exits) { int bestVisits = int.MaxValue; var bestExits = new List(); foreach (var exit in exits) { int visits = exit.ConnectedRoomId.HasValue ? agent.VisitCount(exit.ConnectedRoomId.Value) : 0; if (visits < bestVisits) { bestVisits = visits; bestExits.Clear(); bestExits.Add(exit); } else if (visits == bestVisits) { bestExits.Add(exit); } } return RandomPick(bestExits); } private static Exit RandomPick(List exits) => exits[UnityEngine.Random.Range(0, exits.Count)]; // ---------------------------------------------------------------- peeking /// /// Peek into a room (via an exit) to see if there are monsters and how many. /// Returns the perceived challenge rating, or 0 if no monsters/already explored. /// Agent sees incomplete info (60-100% of actual monsters, doesn't know about hidden ones). /// public float PeekThroughExit(Exit exit, MazeAgent agent) { if (!exit.ConnectedRoomId.HasValue) return 0; // Unresolved exit - can't peek yet Room nextRoom = _generator.Graph.Get(exit.ConnectedRoomId.Value); if (nextRoom == null || nextRoom.Encounter == null) return 0; // No encounter in this room return nextRoom.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet); } /// /// Decide if an exit is worth taking based on threat level inside. /// Returns true if the exit is relatively safe or if the agent has no choice, /// false if the agent decides to retreat from this exit. /// private bool ShouldTakeExit(Exit exit, MazeAgent agent) { if (!exit.ConnectedRoomId.HasValue) return true; // Unresolved exit - proceed cautiously Room nextRoom = _generator.Graph.Get(exit.ConnectedRoomId.Value); if (nextRoom == null || nextRoom.Encounter == null || !nextRoom.Encounter.HasLivingMonsters) return true; // No encounter, safe to enter // Peek and evaluate threat float perceivedCR = nextRoom.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet); // If threat is below threshold, take it. If above, retreat with some randomness. if (perceivedCR <= _retreatThreshold) { NarratePeek(agent, exit, nextRoom, perceivedCR, false); return true; // Manageable threat } // Higher difficulty = more likely to retreat, but add randomness for personality float retreatChance = Mathf.Clamp01((perceivedCR - _retreatThreshold) / 2.0f); bool avoiding = UnityEngine.Random.value <= retreatChance; NarratePeek(agent, exit, nextRoom, perceivedCR, avoiding); return !avoiding; } /// /// Narrates a peek, throttled per room so an agent standing next to a dangerous room /// doesn't spam the same observation every decision tick. /// private void NarratePeek(MazeAgent agent, Exit exit, Room peeked, float threat, bool avoiding) { int narrated = _peekCount.TryGetValue(peeked.Id, out int count) ? count : 0; if (narrated >= MaxNarratedPeeksPerRoom) return; _peekCount[peeked.Id] = narrated + 1; // VisibleMonsterCount is fixed at spawn time and isn't reduced as monsters die, so // clamp it or a half-cleared room reads as busier than it is. int hostiles = Mathf.Min(peeked.Encounter.VisibleMonsterCount, peeked.Encounter.AliveMonsterCount); string heading = CompassName(exit.DirectionDegrees); if (avoiding) Think(ThoughtKind.Avoid, $"Peeked {heading} into room {peeked.Id}: ~{hostiles} hostile(s), threat {threat:0.00} - too rich for me, looking for another door."); else Think(ThoughtKind.Peek, $"Peeked {heading} into room {peeked.Id}: ~{hostiles} hostile(s), threat {threat:0.00} - that's manageable."); } /// /// Filter exits: remove those the agent wants to retreat from due to high threat. /// This is called during exit selection so dangerous rooms are avoided when possible. /// private List FilterByThreat(List exits, MazeAgent agent) { var safeExits = new List(); foreach (var exit in exits) { if (ShouldTakeExit(exit, agent)) safeExits.Add(exit); } // If all exits are dangerous, agent must take one anyway (don't get stuck) return safeExits.Count > 0 ? safeExits : exits; } /// Exits are cardinal-only, so a direction always has an exact compass name. private static string CompassName(float directionDegrees) { int normalized = Mathf.RoundToInt(Mathf.Repeat(directionDegrees, 360f) / 90f) % 4; switch (normalized) { case 0: return "east"; case 1: return "north"; case 2: return "west"; default: return "south"; } } } }