AutonomousAgentBrain.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using UnityEngine;
  5. namespace MazeGen
  6. {
  7. /// <summary>
  8. /// Drives one agent's autonomous exploration: waits out a (randomized) initial delay so
  9. /// many agents don't all decide in lockstep, then ticks a steady decision interval -
  10. /// each time it fires, the agent deals with whatever is in its current room (fight it,
  11. /// join a fight already forming, or sneak past) and otherwise picks an exit and moves.
  12. /// Stops deciding once the agent reaches an exit room, dies, or the level ends.
  13. ///
  14. /// Agents can "peek" into adjacent rooms before entering to see if there are monsters.
  15. /// Based on the perceived threat level, they may choose a different exit rather than fight.
  16. ///
  17. /// Every decision this class makes is narrated into the agent's <see cref="AgentThoughtLog"/>,
  18. /// which is what the HUD shows while an agent is being followed. Adding a decision here
  19. /// without a matching Think() call makes the agent's behaviour unexplainable to the observer,
  20. /// so keep them together.
  21. ///
  22. /// All timing runs off <see cref="SimulationClock"/>, never Time.deltaTime directly, so a
  23. /// paused simulation genuinely freezes every agent.
  24. /// </summary>
  25. public class AutonomousAgentBrain : MonoBehaviour
  26. {
  27. /// <summary>Peek observations about the same room are only narrated this many times, to keep the thought log readable.</summary>
  28. private const int MaxNarratedPeeksPerRoom = 2;
  29. private MazeAgentController _controller;
  30. private MazeGenerator _generator;
  31. private CombatDirector _director;
  32. private float _decisionInterval;
  33. private float _exploreChance;
  34. private float _retreatThreshold = 1.5f; // CR above this = don't pick that fight
  35. private Func<bool> _isLevelEnded;
  36. private float _timer;
  37. private readonly Dictionary<int, int> _peekCount = new Dictionary<int, int>(); // narration throttle, per room id
  38. public void Initialize(MazeAgentController controller, MazeGenerator generator, CombatDirector director, float initialDelay, float decisionInterval, float exploreChance, float retreatThreshold, Func<bool> isLevelEnded)
  39. {
  40. _controller = controller;
  41. _generator = generator;
  42. _director = director;
  43. _decisionInterval = Mathf.Max(0.05f, decisionInterval);
  44. _exploreChance = Mathf.Clamp01(exploreChance);
  45. _retreatThreshold = Mathf.Max(0, retreatThreshold);
  46. _isLevelEnded = isLevelEnded;
  47. _timer = Mathf.Max(0f, initialDelay);
  48. }
  49. private void Update()
  50. {
  51. MazeAgent agent = _controller?.Agent;
  52. if (agent == null || agent.IsFinished || agent.IsDead) return;
  53. if (_isLevelEnded != null && _isLevelEnded()) return;
  54. // Committed to a fight: the CombatDirector owns this agent until it resolves.
  55. if (agent.IsEngaged) return;
  56. _timer -= SimulationClock.DeltaTime;
  57. if (_timer > 0f) return;
  58. _timer = _decisionInterval;
  59. if (HandleRoomHostiles(agent)) return;
  60. MoveOn(agent);
  61. }
  62. private void Think(ThoughtKind kind, string text) => _controller?.Agent?.Thoughts.Add(kind, text);
  63. // ---------------------------------------------------------------- fighting & sneaking
  64. /// <summary>
  65. /// Deals with hostiles in the room the agent is standing in. Returns true if this took
  66. /// the agent's turn (it committed to a fight, or spent the tick slipping away).
  67. /// </summary>
  68. private bool HandleRoomHostiles(MazeAgent agent)
  69. {
  70. if (_director == null) return false;
  71. Room room = agent.CurrentRoom;
  72. if (room == null) return false;
  73. // A fight is already mustering here - pile in for better odds, or try to slip past.
  74. PendingFight mustering = _director.FindMusteringFight(room);
  75. if (mustering != null && !mustering.Contains(agent))
  76. return HandleArrivalAtFight(agent, mustering);
  77. if (room.Encounter == null || !room.Encounter.HasLivingMonsters) return false;
  78. int hostiles = room.Encounter.AliveMonsterCount;
  79. float threat = room.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
  80. // Already tried and failed to finish these off once - don't loop on it.
  81. if (agent.HasFledFrom(room.Id))
  82. {
  83. Think(ThoughtKind.Avoid, $"Still {hostiles} hostile(s) here and I've already failed to beat them - leaving.");
  84. return false; // fall through to MoveOn so it actually walks out this tick
  85. }
  86. if (threat <= _retreatThreshold)
  87. {
  88. Think(ThoughtKind.Fight,
  89. $"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.");
  90. _director.DeclareFight(room, agent, $"sized up {hostiles} hostile(s) at threat {threat:0.00} and attacked");
  91. return true;
  92. }
  93. // Too dangerous to pick - try to get out without a brawl.
  94. 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.");
  95. if (_director.TrySneakOut(agent, out float odds))
  96. {
  97. Think(ThoughtKind.Sneak, $"Slipped away unnoticed ({odds:P0} odds).");
  98. return false; // MoveOn carries it out of the room this same tick
  99. }
  100. Think(ThoughtKind.Sneak, $"Botched the sneak ({odds:P0} odds) - they've seen me, no choice but to fight.");
  101. _director.DeclareFight(room, agent, $"was caught trying to sneak out past {hostiles} hostile(s)");
  102. return true;
  103. }
  104. /// <summary>
  105. /// The agent walked into a room where a fight is already forming. It can join (raising
  106. /// the odds for everyone) or try to sneak by - and a botched sneak forces it in anyway.
  107. /// </summary>
  108. private bool HandleArrivalAtFight(MazeAgent agent, PendingFight fight)
  109. {
  110. float threat = fight.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
  111. // The threat is shared: joining N-1 allies makes the same monsters worth risking.
  112. float sharedThreat = threat / (fight.Agents.Count + 1);
  113. bool wantsToSneak = sharedThreat > _retreatThreshold || agent.HasFledFrom(fight.Room.Id);
  114. if (!wantsToSneak)
  115. Think(ThoughtKind.Fight, $"Walked into fight #{fight.Id} - {fight.Agents.Count} ally(s) already in, shared threat {sharedThreat:0.00}. Joining.");
  116. else
  117. Think(ThoughtKind.Sneak, $"Walked into fight #{fight.Id} - shared threat {sharedThreat:0.00} still over my limit. Trying to sneak past it.");
  118. switch (_director.ResolveArrival(agent, wantsToSneak))
  119. {
  120. case CombatDirector.ArrivalDecision.Joined:
  121. case CombatDirector.ArrivalDecision.SneakFailedAndJoined:
  122. return true; // engaged now; the director takes it from here
  123. case CombatDirector.ArrivalDecision.SneakedPast:
  124. // Made it past - keep going straight through into the next room.
  125. Think(ThoughtKind.Sneak, "Made it past them - pressing on into the next room.");
  126. return false;
  127. default:
  128. return false;
  129. }
  130. }
  131. // ---------------------------------------------------------------- movement
  132. private void MoveOn(MazeAgent agent)
  133. {
  134. Exit choice = ChooseExit(agent, agent.CurrentRoom);
  135. if (choice == null)
  136. {
  137. Think(ThoughtKind.Move, $"No usable door out of room {agent.CurrentRoom.Id} - waiting.");
  138. return;
  139. }
  140. string heading = CompassName(choice.DirectionDegrees);
  141. Room known = choice.ConnectedRoomId.HasValue ? _generator.Graph.Get(choice.ConnectedRoomId.Value) : null;
  142. string destination = known != null
  143. ? $"room {known.Id}{(agent.HasVisited(known.Id) ? " (been there)" : " (new to me)")}"
  144. : "whatever is behind it (never opened)";
  145. if (_controller.TryUseExit(choice))
  146. Think(ThoughtKind.Move, $"Taking the {heading} door into {destination}.");
  147. else
  148. Think(ThoughtKind.Move, $"The {heading} door out of room {agent.CurrentRoom.Id} went nowhere - picking again next tick.");
  149. }
  150. /// <summary>
  151. /// Picks the next exit to take. Strongly prefers an unused exit (fresh, unexplored
  152. /// territory); failing that, prefers a used exit into a room this agent personally
  153. /// hasn't visited yet (following where another agent has already been); only
  154. /// backtracks into a room it's already visited as a last resort. `exploreChance`
  155. /// adds some randomness so a "follow" opportunity is occasionally taken even when
  156. /// exploring is available, rather than being 100% deterministic.
  157. ///
  158. /// A few anti-stuck rules keep this from looping forever around a small ring of
  159. /// already-explored rooms (e.g. a square formed by a loop-closing connection):
  160. /// - The door leading straight back to the room the agent just came from is excluded
  161. /// from consideration unless it's the only door available at all, so agents don't
  162. /// ping-pong between two rooms.
  163. /// - When forced to backtrack, exits that "peek" into a room with its own unexplored
  164. /// option beyond it (an unused exit, or one leading somewhere this agent hasn't
  165. /// personally seen) are preferred over ones that only lead deeper into fully-picked-
  166. /// over territory - this steers the agent back out of a dead loop instead of just
  167. /// circling it.
  168. /// - Ties are broken toward whichever room this agent has personally visited the
  169. /// fewest times, so a small loop gets cycled through rather than bounced around.
  170. ///
  171. /// Also evaluates monster threats in adjacent rooms - if a room looks too dangerous,
  172. /// the agent will retreat (choose a different exit) if possible.
  173. /// </summary>
  174. private Exit ChooseExit(MazeAgent agent, Room room)
  175. {
  176. Exit reverseExit = FindReverseExit(agent, room);
  177. var explore = new List<Exit>();
  178. var follow = new List<Exit>();
  179. var backtrack = new List<Exit>();
  180. foreach (var exit in room.Exits)
  181. {
  182. if (exit == reverseExit) continue;
  183. if (exit.IsBlocked) continue; // Skip exits that are permanently unusable
  184. if (!exit.IsUsed)
  185. {
  186. explore.Add(exit);
  187. continue;
  188. }
  189. bool leadsToVisited = exit.ConnectedRoomId.HasValue && agent.HasVisited(exit.ConnectedRoomId.Value);
  190. (leadsToVisited ? backtrack : follow).Add(exit);
  191. }
  192. // Filter exits by threat level - remove dangerous ones if safer options exist
  193. explore = FilterByThreat(explore, agent);
  194. follow = FilterByThreat(follow, agent);
  195. backtrack = FilterByThreat(backtrack, agent);
  196. if (explore.Count > 0 && UnityEngine.Random.value < _exploreChance)
  197. return RandomPick(explore);
  198. if (follow.Count > 0)
  199. return LeastVisitedPick(agent, follow);
  200. if (explore.Count > 0)
  201. return RandomPick(explore);
  202. if (backtrack.Count > 0)
  203. return ChooseBacktrack(agent, backtrack);
  204. // Excluding the reverse door left nothing - it's genuinely the only way out
  205. // (e.g. a single-exit dead end), so take it (even if dangerous).
  206. return reverseExit;
  207. }
  208. /// <summary>The door in `room` that leads straight back to wherever the agent's last move came from, if any.</summary>
  209. private static Exit FindReverseExit(MazeAgent agent, Room room)
  210. {
  211. int? reverseExitId = agent.LastUsedExit?.ConnectedExitId;
  212. if (!reverseExitId.HasValue) return null;
  213. return room.Exits.Find(e => e.Id == reverseExitId.Value);
  214. }
  215. private Exit ChooseBacktrack(MazeAgent agent, List<Exit> backtrack)
  216. {
  217. var promising = backtrack.Where(e => LeadsTowardUnexploredOptions(agent, e)).ToList();
  218. return LeastVisitedPick(agent, promising.Count > 0 ? promising : backtrack);
  219. }
  220. /// <summary>
  221. /// One-step lookahead: does the room beyond `exit` itself have any door (other than
  222. /// the one leading straight back here) that's still unused or leads somewhere this
  223. /// agent hasn't personally been? If so, backtracking this way still has somewhere
  224. /// useful to go; if not, it just leads deeper into already-picked-over territory.
  225. /// </summary>
  226. private bool LeadsTowardUnexploredOptions(MazeAgent agent, Exit exit)
  227. {
  228. if (!exit.ConnectedRoomId.HasValue) return false;
  229. Room target = _generator.Graph.Get(exit.ConnectedRoomId.Value);
  230. if (target == null) return false;
  231. int reverseExitId = exit.ConnectedExitId ?? -1;
  232. foreach (var candidate in target.Exits)
  233. {
  234. if (candidate.Id == reverseExitId) continue;
  235. if (!candidate.IsUsed) return true;
  236. if (candidate.ConnectedRoomId.HasValue && !agent.HasVisited(candidate.ConnectedRoomId.Value)) return true;
  237. }
  238. return false;
  239. }
  240. private static Exit LeastVisitedPick(MazeAgent agent, List<Exit> exits)
  241. {
  242. int bestVisits = int.MaxValue;
  243. var bestExits = new List<Exit>();
  244. foreach (var exit in exits)
  245. {
  246. int visits = exit.ConnectedRoomId.HasValue ? agent.VisitCount(exit.ConnectedRoomId.Value) : 0;
  247. if (visits < bestVisits)
  248. {
  249. bestVisits = visits;
  250. bestExits.Clear();
  251. bestExits.Add(exit);
  252. }
  253. else if (visits == bestVisits)
  254. {
  255. bestExits.Add(exit);
  256. }
  257. }
  258. return RandomPick(bestExits);
  259. }
  260. private static Exit RandomPick(List<Exit> exits) => exits[UnityEngine.Random.Range(0, exits.Count)];
  261. // ---------------------------------------------------------------- peeking
  262. /// <summary>
  263. /// Peek into a room (via an exit) to see if there are monsters and how many.
  264. /// Returns the perceived challenge rating, or 0 if no monsters/already explored.
  265. /// Agent sees incomplete info (60-100% of actual monsters, doesn't know about hidden ones).
  266. /// </summary>
  267. public float PeekThroughExit(Exit exit, MazeAgent agent)
  268. {
  269. if (!exit.ConnectedRoomId.HasValue)
  270. return 0; // Unresolved exit - can't peek yet
  271. Room nextRoom = _generator.Graph.Get(exit.ConnectedRoomId.Value);
  272. if (nextRoom == null || nextRoom.Encounter == null)
  273. return 0; // No encounter in this room
  274. return nextRoom.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
  275. }
  276. /// <summary>
  277. /// Decide if an exit is worth taking based on threat level inside.
  278. /// Returns true if the exit is relatively safe or if the agent has no choice,
  279. /// false if the agent decides to retreat from this exit.
  280. /// </summary>
  281. private bool ShouldTakeExit(Exit exit, MazeAgent agent)
  282. {
  283. if (!exit.ConnectedRoomId.HasValue)
  284. return true; // Unresolved exit - proceed cautiously
  285. Room nextRoom = _generator.Graph.Get(exit.ConnectedRoomId.Value);
  286. if (nextRoom == null || nextRoom.Encounter == null || !nextRoom.Encounter.HasLivingMonsters)
  287. return true; // No encounter, safe to enter
  288. // Peek and evaluate threat
  289. float perceivedCR = nextRoom.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
  290. // If threat is below threshold, take it. If above, retreat with some randomness.
  291. if (perceivedCR <= _retreatThreshold)
  292. {
  293. NarratePeek(agent, exit, nextRoom, perceivedCR, false);
  294. return true; // Manageable threat
  295. }
  296. // Higher difficulty = more likely to retreat, but add randomness for personality
  297. float retreatChance = Mathf.Clamp01((perceivedCR - _retreatThreshold) / 2.0f);
  298. bool avoiding = UnityEngine.Random.value <= retreatChance;
  299. NarratePeek(agent, exit, nextRoom, perceivedCR, avoiding);
  300. return !avoiding;
  301. }
  302. /// <summary>
  303. /// Narrates a peek, throttled per room so an agent standing next to a dangerous room
  304. /// doesn't spam the same observation every decision tick.
  305. /// </summary>
  306. private void NarratePeek(MazeAgent agent, Exit exit, Room peeked, float threat, bool avoiding)
  307. {
  308. int narrated = _peekCount.TryGetValue(peeked.Id, out int count) ? count : 0;
  309. if (narrated >= MaxNarratedPeeksPerRoom) return;
  310. _peekCount[peeked.Id] = narrated + 1;
  311. // VisibleMonsterCount is fixed at spawn time and isn't reduced as monsters die, so
  312. // clamp it or a half-cleared room reads as busier than it is.
  313. int hostiles = Mathf.Min(peeked.Encounter.VisibleMonsterCount, peeked.Encounter.AliveMonsterCount);
  314. string heading = CompassName(exit.DirectionDegrees);
  315. if (avoiding)
  316. Think(ThoughtKind.Avoid, $"Peeked {heading} into room {peeked.Id}: ~{hostiles} hostile(s), threat {threat:0.00} - too rich for me, looking for another door.");
  317. else
  318. Think(ThoughtKind.Peek, $"Peeked {heading} into room {peeked.Id}: ~{hostiles} hostile(s), threat {threat:0.00} - that's manageable.");
  319. }
  320. /// <summary>
  321. /// Filter exits: remove those the agent wants to retreat from due to high threat.
  322. /// This is called during exit selection so dangerous rooms are avoided when possible.
  323. /// </summary>
  324. private List<Exit> FilterByThreat(List<Exit> exits, MazeAgent agent)
  325. {
  326. var safeExits = new List<Exit>();
  327. foreach (var exit in exits)
  328. {
  329. if (ShouldTakeExit(exit, agent))
  330. safeExits.Add(exit);
  331. }
  332. // If all exits are dangerous, agent must take one anyway (don't get stuck)
  333. return safeExits.Count > 0 ? safeExits : exits;
  334. }
  335. /// <summary>Exits are cardinal-only, so a direction always has an exact compass name.</summary>
  336. private static string CompassName(float directionDegrees)
  337. {
  338. int normalized = Mathf.RoundToInt(Mathf.Repeat(directionDegrees, 360f) / 90f) % 4;
  339. switch (normalized)
  340. {
  341. case 0: return "east";
  342. case 1: return "north";
  343. case 2: return "west";
  344. default: return "south";
  345. }
  346. }
  347. }
  348. }