Browse Source

Combat system active

Axel Nordh 1 tháng trước cách đây
mục cha
commit
cc777b4253
66 tập tin đã thay đổi với 3125 bổ sung và 256 xóa
  1. 8 0
      Assets/Resources.meta
  2. 8 0
      Assets/Resources/Characters.meta
  3. 8 0
      Assets/Resources/Characters/Monsters.meta
  4. 26 0
      Assets/Resources/Characters/Monsters/Goblin.asset
  5. 8 0
      Assets/Resources/Characters/Monsters/Goblin.asset.meta
  6. 8 0
      Assets/Resources/Items.meta
  7. 8 0
      Assets/Resources/Items/Armor.meta
  8. 22 0
      Assets/Resources/Items/Armor/BasicLeatherArmor.asset
  9. 8 0
      Assets/Resources/Items/Armor/BasicLeatherArmor.asset.meta
  10. 8 0
      Assets/Resources/Items/Consumables.meta
  11. 22 0
      Assets/Resources/Items/Consumables/HealthPotion.asset
  12. 8 0
      Assets/Resources/Items/Consumables/HealthPotion.asset.meta
  13. 8 0
      Assets/Resources/Items/Weapons.meta
  14. 22 0
      Assets/Resources/Items/Weapons/IronSword.asset
  15. 8 0
      Assets/Resources/Items/Weapons/IronSword.asset.meta
  16. 18 3
      Assets/Scenes/SampleScene.unity
  17. 84 0
      Assets/Scripts/Maze/Agent/AgentThoughtLog.cs
  18. 2 0
      Assets/Scripts/Maze/Agent/AgentThoughtLog.cs.meta
  19. 181 13
      Assets/Scripts/Maze/Agent/AutonomousAgentBrain.cs
  20. 37 5
      Assets/Scripts/Maze/Agent/MazeAgent.cs
  21. 16 2
      Assets/Scripts/Maze/Agent/MazeAgentController.cs
  22. 1 1
      Assets/Scripts/Maze/Agent/MazeGenerationSettings-1.asset
  23. 23 0
      Assets/Scripts/Maze/Agent/NameGenerator.cs
  24. 2 0
      Assets/Scripts/Maze/Agent/NameGenerator.cs.meta
  25. 24 7
      Assets/Scripts/Maze/COPY_PASTE_EXAMPLES.csEXAMPLE
  26. 7 0
      Assets/Scripts/Maze/COPY_PASTE_EXAMPLES.csEXAMPLE.meta
  27. 0 2
      Assets/Scripts/Maze/Combat/COPY_PASTE_EXAMPLES.cs.meta
  28. 17 1
      Assets/Scripts/Maze/Combat/CharacterSheet.cs
  29. 518 0
      Assets/Scripts/Maze/Combat/CombatDirector.cs
  30. 2 0
      Assets/Scripts/Maze/Combat/CombatDirector.cs.meta
  31. 82 0
      Assets/Scripts/Maze/Combat/CombatEvent.cs
  32. 2 0
      Assets/Scripts/Maze/Combat/CombatEvent.cs.meta
  33. 120 136
      Assets/Scripts/Maze/Combat/CombatResolver.cs
  34. 64 0
      Assets/Scripts/Maze/Combat/Combatant.cs
  35. 2 0
      Assets/Scripts/Maze/Combat/Combatant.cs.meta
  36. 8 0
      Assets/Scripts/Maze/Combat/Editor.meta
  37. 78 0
      Assets/Scripts/Maze/Combat/Editor/ItemCreationMenu.cs
  38. 2 0
      Assets/Scripts/Maze/Combat/Editor/ItemCreationMenu.cs.meta
  39. 64 0
      Assets/Scripts/Maze/Combat/Editor/MonsterCreationMenu.cs
  40. 2 0
      Assets/Scripts/Maze/Combat/Editor/MonsterCreationMenu.cs.meta
  41. 107 0
      Assets/Scripts/Maze/Combat/ITEM_CREATION_GUIDE.md
  42. 7 0
      Assets/Scripts/Maze/Combat/ITEM_CREATION_GUIDE.md.meta
  43. 43 0
      Assets/Scripts/Maze/Combat/Items/Consumable.cs
  44. 2 0
      Assets/Scripts/Maze/Combat/Items/Consumable.cs.meta
  45. 106 0
      Assets/Scripts/Maze/Combat/MONSTER_SPAWNING_SETUP.md
  46. 7 0
      Assets/Scripts/Maze/Combat/MONSTER_SPAWNING_SETUP.md.meta
  47. 7 1
      Assets/Scripts/Maze/Combat/Monsters/Monster.cs
  48. 74 0
      Assets/Scripts/Maze/Combat/PendingFight.cs
  49. 2 0
      Assets/Scripts/Maze/Combat/PendingFight.cs.meta
  50. 27 0
      Assets/Scripts/Maze/Generation/MazeGenerator.cs
  51. 10 0
      Assets/Scripts/Maze/Input/MazeInput.cs
  52. 8 0
      Assets/Scripts/Maze/Simulation.meta
  53. 68 0
      Assets/Scripts/Maze/Simulation/SimulationClock.cs
  54. 2 0
      Assets/Scripts/Maze/Simulation/SimulationClock.cs.meta
  55. 525 0
      Assets/Scripts/Maze/Visualization/BattleArenaView.cs
  56. 2 0
      Assets/Scripts/Maze/Visualization/BattleArenaView.cs.meta
  57. 226 12
      Assets/Scripts/Maze/Visualization/MazeSceneBuilder.cs
  58. 146 0
      Assets/Scripts/Maze/Visualization/MonsterSpawner.cs
  59. 2 0
      Assets/Scripts/Maze/Visualization/MonsterSpawner.cs.meta
  60. 39 0
      Assets/Scripts/Maze/Visualization/MonsterView.cs
  61. 2 0
      Assets/Scripts/Maze/Visualization/MonsterView.cs.meta
  62. 77 13
      CLAUDE.md
  63. 3 26
      ProjectSettings/TagManager.asset
  64. 3 0
      UserSettings/EditorUserSettings.asset
  65. 44 34
      UserSettings/Layouts/default-6000.dwlt
  66. 50 0
      UserSettings/Search.settings

+ 8 - 0
Assets/Resources.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d2d1816fbbd75ca4fb20d473c9fada2f
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 8 - 0
Assets/Resources/Characters.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 18986a58e5b3d2e48a3baa0a017ad0bd
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 8 - 0
Assets/Resources/Characters/Monsters.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 10ca2268249145947bd678f715bdd6ab
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 26 - 0
Assets/Resources/Characters/Monsters/Goblin.asset

@@ -0,0 +1,26 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+  m_ObjectHideFlags: 0
+  m_CorrespondingSourceObject: {fileID: 0}
+  m_PrefabInstance: {fileID: 0}
+  m_PrefabAsset: {fileID: 0}
+  m_GameObject: {fileID: 0}
+  m_Enabled: 1
+  m_EditorHideFlags: 0
+  m_Script: {fileID: 11500000, guid: 30bb08bf6a5534343a5ce2330c7a3b8a, type: 3}
+  m_Name: Goblin
+  m_EditorClassIdentifier: Assembly-CSharp::MazeGen.Monster
+  _monsterName: Goblin
+  _color: {r: 0, g: 1, b: 0, a: 1}
+  _baseStats:
+    _maxHealth: 20
+    _speed: 10
+    _attack: 3
+    _defense: 3
+    _magic: 0
+  _startingWeapon: {fileID: 0}
+  _startingArmor: {fileID: 0}
+  _possibleLoot:
+  - {fileID: 11400000, guid: 8f28b5b035a97134d976d952b65c75d2, type: 2}

+ 8 - 0
Assets/Resources/Characters/Monsters/Goblin.asset.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 1c986065044bfad468fa052c164aca35
+NativeFormatImporter:
+  externalObjects: {}
+  mainObjectFileID: 11400000
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 8 - 0
Assets/Resources/Items.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 20f2a6c26e8478044905708f6cc7a439
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 8 - 0
Assets/Resources/Items/Armor.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 66c9f3ec3077e8d4ab6533fdaa95a5d6
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 22 - 0
Assets/Resources/Items/Armor/BasicLeatherArmor.asset

@@ -0,0 +1,22 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+  m_ObjectHideFlags: 0
+  m_CorrespondingSourceObject: {fileID: 0}
+  m_PrefabInstance: {fileID: 0}
+  m_PrefabAsset: {fileID: 0}
+  m_GameObject: {fileID: 0}
+  m_Enabled: 1
+  m_EditorHideFlags: 0
+  m_Script: {fileID: 11500000, guid: 53a075599a74c6b4ea91be7c20e5dcf1, type: 3}
+  m_Name: BasicLeatherArmor
+  m_EditorClassIdentifier: Assembly-CSharp::MazeGen.Armor
+  _itemName: Leather armor
+  _description: 
+  _icon: {fileID: 0}
+  _rarity: 1
+  _defenseBonus: 5
+  _healthBonus: 0
+  _speedPenalty: 0
+  _magicResistance: 0

+ 8 - 0
Assets/Resources/Items/Armor/BasicLeatherArmor.asset.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: dce41d7fb5a548147b13c6f5d1752217
+NativeFormatImporter:
+  externalObjects: {}
+  mainObjectFileID: 11400000
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 8 - 0
Assets/Resources/Items/Consumables.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 1d9c4d90e832d394d96d009c71943a3d
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 22 - 0
Assets/Resources/Items/Consumables/HealthPotion.asset

@@ -0,0 +1,22 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+  m_ObjectHideFlags: 0
+  m_CorrespondingSourceObject: {fileID: 0}
+  m_PrefabInstance: {fileID: 0}
+  m_PrefabAsset: {fileID: 0}
+  m_GameObject: {fileID: 0}
+  m_Enabled: 1
+  m_EditorHideFlags: 0
+  m_Script: {fileID: 11500000, guid: 680345347eaf82a4e979dcfffcdf848a, type: 3}
+  m_Name: HealthPotion
+  m_EditorClassIdentifier: Assembly-CSharp::MazeGen.Consumable
+  _itemName: Small health potion
+  _description: A small health potion, gives 5 health back
+  _icon: {fileID: 0}
+  _rarity: 1
+  _defenseBonus: 0
+  _healthBonus: 5
+  _speedBonus: 0
+  _magicResistance: 0

+ 8 - 0
Assets/Resources/Items/Consumables/HealthPotion.asset.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 8f28b5b035a97134d976d952b65c75d2
+NativeFormatImporter:
+  externalObjects: {}
+  mainObjectFileID: 11400000
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 8 - 0
Assets/Resources/Items/Weapons.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 579442950d0ed334e889c91208b55352
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 22 - 0
Assets/Resources/Items/Weapons/IronSword.asset

@@ -0,0 +1,22 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+  m_ObjectHideFlags: 0
+  m_CorrespondingSourceObject: {fileID: 0}
+  m_PrefabInstance: {fileID: 0}
+  m_PrefabAsset: {fileID: 0}
+  m_GameObject: {fileID: 0}
+  m_Enabled: 1
+  m_EditorHideFlags: 0
+  m_Script: {fileID: 11500000, guid: 6b3a08b2878b73c42a665da9e02be28f, type: 3}
+  m_Name: IronSword
+  m_EditorClassIdentifier: Assembly-CSharp::MazeGen.Weapon
+  _itemName: Iron Sword
+  _description: A plain iron sword
+  _icon: {fileID: 0}
+  _rarity: 1
+  _attackBonus: 5
+  _magicBonus: 0
+  _defenseBonus: 0
+  _speedBonus: 0

+ 8 - 0
Assets/Resources/Items/Weapons/IronSword.asset.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 78a4a095c9a6c3e47bb16500164778b6
+NativeFormatImporter:
+  externalObjects: {}
+  mainObjectFileID: 11400000
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 18 - 3
Assets/Scenes/SampleScene.unity

@@ -275,8 +275,6 @@ MonoBehaviour:
   zoomSpeed: 150
   minOrthographicSize: 2
   maxOrthographicSize: 150
-  minDistance: 5
-  maxDistance: 50
 --- !u!1 &410087039
 GameObject:
   m_ObjectHideFlags: 0
@@ -415,6 +413,7 @@ GameObject:
   - component: {fileID: 569980087}
   - component: {fileID: 569980086}
   - component: {fileID: 569980085}
+  - component: {fileID: 569980088}
   m_Layer: 0
   m_Name: MazeSystem
   m_TagString: Untagged
@@ -434,7 +433,7 @@ MonoBehaviour:
   m_Script: {fileID: 11500000, guid: 1f0deb6ed9b595d46a4e43d7c07dbf57, type: 3}
   m_Name: 
   m_EditorClassIdentifier: Assembly-CSharp::MazeGen.MazeSceneBuilder
-  agentCount: 100
+  agentCount: 25
   spawnStaggerRange: {x: 0, y: 0.1}
   initialDecisionDelayRange: {x: 0.5, y: 2}
   decisionInterval: 1.5
@@ -454,6 +453,9 @@ MonoBehaviour:
   m_Name: 
   m_EditorClassIdentifier: Assembly-CSharp::MazeGen.MazeGenerator
   settings: {fileID: 11400000, guid: 0e7402fed2df46c499f9da60cf76b358, type: 2}
+  encounterChance: 0.7
+  minMonstersPerRoom: 1
+  maxMonstersPerRoom: 3
 --- !u!4 &569980087
 Transform:
   m_ObjectHideFlags: 0
@@ -469,6 +471,19 @@ Transform:
   m_Children: []
   m_Father: {fileID: 0}
   m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &569980088
+MonoBehaviour:
+  m_ObjectHideFlags: 0
+  m_CorrespondingSourceObject: {fileID: 0}
+  m_PrefabInstance: {fileID: 0}
+  m_PrefabAsset: {fileID: 0}
+  m_GameObject: {fileID: 569980084}
+  m_Enabled: 1
+  m_EditorHideFlags: 0
+  m_Script: {fileID: 11500000, guid: 892c8132158cf9a488fb71e284f183f6, type: 3}
+  m_Name: 
+  m_EditorClassIdentifier: Assembly-CSharp::MazeGen.MonsterSpawner
+  mazeGenerator: {fileID: 569980086}
 --- !u!1 &832575517
 GameObject:
   m_ObjectHideFlags: 0

+ 84 - 0
Assets/Scripts/Maze/Agent/AgentThoughtLog.cs

@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MazeGen
+{
+    /// <summary>What kind of decision a thought records - drives its color in the HUD.</summary>
+    public enum ThoughtKind
+    {
+        Move,       // "moving to room X"
+        Peek,       // "looked through a door and saw N monsters"
+        Avoid,      // "that room is too dangerous, picking another exit"
+        Fight,      // "engaging / joining a fight"
+        Sneak,      // "trying to slip past a fight"
+        Combat,     // blow-by-blow results the agent was involved in
+        Outcome,    // fight won/lost/fled
+        Goal        // reached the exit room, died
+    }
+
+    public readonly struct Thought
+    {
+        public readonly float Time;
+        public readonly ThoughtKind Kind;
+        public readonly string Text;
+
+        public Thought(float time, ThoughtKind kind, string text)
+        {
+            Time = time;
+            Kind = kind;
+            Text = text;
+        }
+    }
+
+    /// <summary>
+    /// A bounded, newest-wins journal of one agent's decisions, so the observer can follow an
+    /// agent and read *why* it did what it did. Capped so that thousands of decisions over a
+    /// long run can't grow without bound - this is a debugging/observation surface, not history
+    /// the simulation itself ever reads back.
+    /// </summary>
+    public class AgentThoughtLog
+    {
+        private readonly List<Thought> _entries = new List<Thought>();
+        private readonly int _capacity;
+
+        // Generous, because a single big fight's blow-by-blow gets mirrored in here and would
+        // otherwise push out all the exploration reasoning that explains how the agent got there.
+        public AgentThoughtLog(int capacity = 150)
+        {
+            _capacity = Mathf.Max(1, capacity);
+        }
+
+        /// <summary>Oldest first. Iterate backwards for a newest-first display.</summary>
+        public IReadOnlyList<Thought> Entries => _entries;
+
+        public event Action<Thought> Added;
+
+        public void Add(ThoughtKind kind, string text)
+        {
+            if (string.IsNullOrEmpty(text)) return;
+
+            var thought = new Thought(Time.time, kind, text);
+            _entries.Add(thought);
+            if (_entries.Count > _capacity)
+                _entries.RemoveAt(0);
+
+            Added?.Invoke(thought);
+        }
+
+        public static Color ColorFor(ThoughtKind kind)
+        {
+            switch (kind)
+            {
+                case ThoughtKind.Peek: return new Color(0.7f, 0.85f, 1f);
+                case ThoughtKind.Avoid: return new Color(1f, 0.85f, 0.4f);
+                case ThoughtKind.Fight: return new Color(1f, 0.5f, 0.4f);
+                case ThoughtKind.Sneak: return new Color(0.8f, 0.7f, 1f);
+                case ThoughtKind.Combat: return new Color(1f, 0.75f, 0.75f);
+                case ThoughtKind.Outcome: return new Color(0.6f, 1f, 0.7f);
+                case ThoughtKind.Goal: return new Color(0.5f, 1f, 1f);
+                default: return new Color(0.85f, 0.85f, 0.85f);
+            }
+        }
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Agent/AgentThoughtLog.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2380dd901a5d38642a6dfe06ed904ca8

+ 181 - 13
Assets/Scripts/Maze/Agent/AutonomousAgentBrain.cs

@@ -8,28 +8,42 @@ namespace MazeGen
     /// <summary>
     /// 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, picks an exit and asks the MazeAgentController to use it. Stops
-    /// deciding once the agent reaches an exit room or the level has ended.
+    /// 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 to retreat rather than fight.
+    /// 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 <see cref="AgentThoughtLog"/>,
+    /// 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 <see cref="SimulationClock"/>, never Time.deltaTime directly, so a
+    /// paused simulation genuinely freezes every agent.
     /// </summary>
     public class AutonomousAgentBrain : MonoBehaviour
     {
+        /// <summary>Peek observations about the same room are only narrated this many times, to keep the thought log readable.</summary>
+        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 = retreat (configurable)
+        private float _retreatThreshold = 1.5f;     // CR above this = don't pick that fight
         private Func<bool> _isLevelEnded;
 
         private float _timer;
-        private Dictionary<int, int> _peekCount = new Dictionary<int, int>(); // Track how many times agent peeked at a room
+        private readonly Dictionary<int, int> _peekCount = new Dictionary<int, int>(); // narration throttle, per room id
 
-        public void Initialize(MazeAgentController controller, MazeGenerator generator, float initialDelay, float decisionInterval, float exploreChance, float retreatThreshold, Func<bool> isLevelEnded)
+        public void Initialize(MazeAgentController controller, MazeGenerator generator, CombatDirector director, float initialDelay, float decisionInterval, float exploreChance, float retreatThreshold, Func<bool> isLevelEnded)
         {
             _controller = controller;
             _generator = generator;
+            _director = director;
             _decisionInterval = Mathf.Max(0.05f, decisionInterval);
             _exploreChance = Mathf.Clamp01(exploreChance);
             _retreatThreshold = Mathf.Max(0, retreatThreshold);
@@ -39,16 +53,129 @@ namespace MazeGen
 
         private void Update()
         {
-            if (_controller?.Agent == null || _controller.Agent.IsFinished || _controller.Agent.IsDead) return;
+            MazeAgent agent = _controller?.Agent;
+            if (agent == null || agent.IsFinished || agent.IsDead) return;
             if (_isLevelEnded != null && _isLevelEnded()) return;
 
-            _timer -= Time.deltaTime;
+            // 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;
 
-            Exit choice = ChooseExit(_controller.Agent, _controller.Agent.CurrentRoom);
-            if (choice != null)
-                _controller.TryUseExit(choice);
+            if (HandleRoomHostiles(agent)) return;
+            MoveOn(agent);
+        }
+
+        private void Think(ThoughtKind kind, string text) => _controller?.Agent?.Thoughts.Add(kind, text);
+
+        // ---------------------------------------------------------------- fighting & sneaking
+
+        /// <summary>
+        /// 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).
+        /// </summary>
+        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;
+        }
+
+        /// <summary>
+        /// 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.
+        /// </summary>
+        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.");
         }
 
         /// <summary>
@@ -182,6 +309,8 @@ namespace MazeGen
 
         private static Exit RandomPick(List<Exit> exits) => exits[UnityEngine.Random.Range(0, exits.Count)];
 
+        // ---------------------------------------------------------------- peeking
+
         /// <summary>
         /// 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.
@@ -210,7 +339,7 @@ namespace MazeGen
                 return true; // Unresolved exit - proceed cautiously
 
             Room nextRoom = _generator.Graph.Get(exit.ConnectedRoomId.Value);
-            if (nextRoom == null || nextRoom.Encounter == null)
+            if (nextRoom == null || nextRoom.Encounter == null || !nextRoom.Encounter.HasLivingMonsters)
                 return true; // No encounter, safe to enter
 
             // Peek and evaluate threat
@@ -218,11 +347,37 @@ namespace MazeGen
 
             // 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);
-            return UnityEngine.Random.value > retreatChance;
+            bool avoiding = UnityEngine.Random.value <= retreatChance;
+            NarratePeek(agent, exit, nextRoom, perceivedCR, avoiding);
+            return !avoiding;
+        }
+
+        /// <summary>
+        /// Narrates a peek, throttled per room so an agent standing next to a dangerous room
+        /// doesn't spam the same observation every decision tick.
+        /// </summary>
+        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.");
         }
 
         /// <summary>
@@ -242,5 +397,18 @@ namespace MazeGen
             // If all exits are dangerous, agent must take one anyway (don't get stuck)
             return safeExits.Count > 0 ? safeExits : exits;
         }
+
+        /// <summary>Exits are cardinal-only, so a direction always has an exact compass name.</summary>
+        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";
+            }
+        }
     }
 }

+ 37 - 5
Assets/Scripts/Maze/Agent/MazeAgent.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using UnityEngine;
 
 namespace MazeGen
 {
@@ -24,13 +25,31 @@ namespace MazeGen
         /// <summary>This agent's character sheet (stats, equipment, inventory).</summary>
         public CharacterSheet CharacterSheet { get; private set; }
 
-        /// <summary>Track if this agent died (different from IsFinished, which means reached the exit).</summary>
-        public bool IsDead => !CharacterSheet.Stats.IsAlive;
+        /// <summary>
+        /// Track if this agent died (different from IsFinished, which means reached the exit).
+        /// Backed by its own flag rather than derived from health so that Die() stays a
+        /// once-only, event-firing transition even though combat has already zeroed the health
+        /// by the time it's called.
+        /// </summary>
+        public bool IsDead { get; private set; }
+
+        /// <summary>
+        /// True while this agent is committed to a fight (mustering or being resolved). Its brain
+        /// parks itself so it can't wander off out of a fight it already joined.
+        /// </summary>
+        public bool IsEngaged { get; set; }
+
+        /// <summary>What this agent is thinking - decisions, peeks, fight blow-by-blow. Observation only.</summary>
+        public AgentThoughtLog Thoughts { get; } = new AgentThoughtLog();
+
+        /// <summary>Name to show a human: the generated character name, not the GameObject name.</summary>
+        public string DisplayName => CharacterSheet?.Name ?? Name;
 
         private readonly List<Room> _path = new List<Room>();
         private readonly HashSet<int> _visitedRoomIds = new HashSet<int>();
         private readonly HashSet<int> _usedExitIds = new HashSet<int>();
         private readonly Dictionary<int, int> _visitCounts = new Dictionary<int, int>();
+        private readonly HashSet<int> _fledRoomIds = new HashSet<int>();
 
         public IReadOnlyList<Room> PathHistory => _path;
 
@@ -38,16 +57,25 @@ namespace MazeGen
         public event Action<Room> ReachedExitRoom;
         public event Action Died;
 
-        public MazeAgent(string name, Room startRoom, CharacterSheet characterSheet = null)
+        public MazeAgent(GameObject go, Room startRoom, CharacterSheet characterSheet = null)
         {
-            Name = name;
-            CharacterSheet = characterSheet ?? new CharacterSheet(new CharacterStats());
+            go.tag = "Agent";
+            Name = go.name;
+            CharacterSheet = characterSheet ?? new CharacterSheet(go, new CharacterStats());
             EnterRoom(startRoom, null);
         }
 
         public bool HasVisited(int roomId) => _visitedRoomIds.Contains(roomId);
         public bool HasUsedExit(Exit exit) => _usedExitIds.Contains(exit.Id);
 
+        /// <summary>
+        /// Remembers that a fight in this room went nowhere (a stalemate), so the brain leaves
+        /// instead of re-declaring the same unwinnable fight every decision tick forever.
+        /// </summary>
+        public void NoteFledFrom(int roomId) => _fledRoomIds.Add(roomId);
+
+        public bool HasFledFrom(int roomId) => _fledRoomIds.Contains(roomId);
+
         /// <summary>How many times this agent has personally entered the given room (0 if never).</summary>
         public int VisitCount(int roomId) => _visitCounts.TryGetValue(roomId, out var count) ? count : 0;
 
@@ -72,6 +100,7 @@ namespace MazeGen
             if (room.Kind == RoomKind.Exit)
             {
                 IsFinished = true;
+                Thoughts.Add(ThoughtKind.Goal, $"Reached the exit room {room.Id} - done, {NewRoomsVisited} room(s) seen.");
                 ReachedExitRoom?.Invoke(room);
             }
         }
@@ -80,7 +109,10 @@ namespace MazeGen
         public void Die()
         {
             if (IsDead) return;
+            IsDead = true;
+            IsEngaged = false;
             CharacterSheet.Stats.CurrentHealth = 0;
+            Thoughts.Add(ThoughtKind.Goal, $"Died in room {CurrentRoom?.Id.ToString() ?? "?"}.");
             Died?.Invoke();
         }
     }

+ 16 - 2
Assets/Scripts/Maze/Agent/MazeAgentController.cs

@@ -10,9 +10,11 @@ namespace MazeGen
     {
         private const float BaseScale = 0.5f;
         private const float FollowedScale = 0.7f;
+        private const float DeadScale = 0.6f;
 
         private MazeGenerator _generator;
         private SpriteRenderer _sprite;
+        private Color _liveColor = Color.white;
 
         public MazeAgent Agent { get; private set; }
 
@@ -23,24 +25,35 @@ namespace MazeGen
 
         public void Spawn(Room startRoom, Color color)
         {
-            Agent = new MazeAgent(gameObject.name, startRoom);
+            Agent = new MazeAgent(gameObject, startRoom);
             transform.position = startRoom.Position;
             transform.localScale = Vector3.one * BaseScale;
             Agent.RoomEntered += room => transform.position = room.Position;
+            Agent.Died += OnDied;
 
             _sprite = gameObject.AddComponent<SpriteRenderer>();
             _sprite.sprite = RoomView.SharedSquareSprite;
             _sprite.color = color;
             _sprite.sortingOrder = 3;
+            _liveColor = color;
         }
 
         /// <summary>Visually calls out this agent as the one the camera is currently following.</summary>
         public void SetFollowed(bool followed)
         {
-            transform.localScale = Vector3.one * (followed ? FollowedScale : BaseScale);
+            float scale = followed ? FollowedScale : BaseScale;
+            transform.localScale = Vector3.one * (Agent != null && Agent.IsDead ? scale * DeadScale : scale);
             if (_sprite != null) _sprite.sortingOrder = followed ? 5 : 3;
         }
 
+        /// <summary>Dead agents stay on the map as dim, shrunken markers rather than vanishing.</summary>
+        private void OnDied()
+        {
+            transform.localScale *= DeadScale;
+            if (_sprite != null)
+                _sprite.color = new Color(_liveColor.r * 0.35f, _liveColor.g * 0.35f, _liveColor.b * 0.35f, 0.55f);
+        }
+
         /// <summary>
         /// Attempts to move the agent through the given exit. Returns false if the exit
         /// doesn't belong to the agent's current room, if the next room couldn't be generated
@@ -50,6 +63,7 @@ namespace MazeGen
         public bool TryUseExit(Exit exit)
         {
             if (Agent == null || Agent.CurrentRoom == null) return false;
+            if (Agent.IsDead || Agent.IsEngaged) return false; // corpses and committed fighters don't walk
             if (exit.OwnerRoomId != Agent.CurrentRoom.Id) return false;
 
             Room nextRoom = exit.ConnectedRoomId.HasValue

+ 1 - 1
Assets/Scripts/Maze/Agent/MazeGenerationSettings-1.asset

@@ -14,7 +14,7 @@ MonoBehaviour:
   m_EditorClassIdentifier: Assembly-CSharp::MazeGen.MazeGenerationSettings
   minExitsPerRoom: 1
   maxExitsPerRoom: 4
-  startRoomCount: 16
+  startRoomCount: 3
   startRoomSpread: 20
   roomSpacing: 4
   startRoomRadius: 1.2

+ 23 - 0
Assets/Scripts/Maze/Agent/NameGenerator.cs

@@ -0,0 +1,23 @@
+using UnityEngine;
+
+public class NameGenerator : MonoBehaviour
+{
+    private static readonly string[] FirstNames = new string[]
+    {
+        "Alex", "Jordan", "Taylor", "Morgan", "Casey", "Riley", "Jamie", "Cameron",
+        "Drew", "Quinn", "Avery", "Parker", "Reese", "Skyler", "Rowan", "Emerson"
+    };
+
+    private static readonly string[] LastNames = new string[]
+    {
+        "Smith", "Johnson", "Williams", "Brown", "Jones", "Miller", "Davis",
+        "Garcia", "Rodriguez", "Wilson", "Martinez", "Anderson"
+    };
+
+    public static string GenerateRandomName()
+    {
+        string firstName = FirstNames[Random.Range(0, FirstNames.Length)];
+        string lastName = LastNames[Random.Range(0, LastNames.Length)];
+        return $"{firstName} {lastName}";
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Agent/NameGenerator.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 40272b3dec90c86479888d839e867de8

+ 24 - 7
Assets/Scripts/Maze/Combat/COPY_PASTE_EXAMPLES.cs → Assets/Scripts/Maze/COPY_PASTE_EXAMPLES.csEXAMPLE

@@ -6,16 +6,33 @@ using MazeGen;
 
 namespace MazeGen
 {
-    // EXAMPLE 1: Creating a weapon in editor via script
-    [System.Obsolete("This is just an example - create weapons via Create > Weapon in editor instead")]
+    // EXAMPLE 1: Creating a weapon in the Unity Editor
+    // HOW TO USE:
+    // 1. Right-click in the Project window (Assets folder or Assets/Resources/Items/Weapons/)
+    // 2. Select "Create > Weapon"
+    // 3. Name your weapon asset (e.g., "IronSword.asset")
+    // 4. Select the created asset and edit its properties in the Inspector:
+    //    - Item Name: "Iron Sword"
+    //    - Description: "A sturdy iron sword"
+    //    - Icon: (drag a sprite)
+    //    - Rarity: 1 (common) to 5 (legendary)
+    //    - Attack Bonus: 5.0
+    //    - Magic Bonus: 0.0
+    //    - Defense Bonus: 1.0
+    //    - Speed Bonus: 0.0
+    //
+    // The asset is automatically saved to Resources/Items/Weapons/ for easy runtime loading.
+    [System.Obsolete("See instructions above - create via Editor instead")]
     public class WeaponCreationExample : MonoBehaviour
     {
-        public void CreateExampleWeapon()
+        // Example of loading weapons at runtime
+        private void LoadWeaponsExample()
         {
-            var weapon = ScriptableObject.CreateInstance<Weapon>();
-            weapon.name = "Iron Sword";
-            // Note: You'll need to expose attack bonus as public or via reflection to set it
-            // Best practice: create via editor!
+            Weapon[] allWeapons = Resources.LoadAll<Weapon>("Items/Weapons/");
+            foreach (var weapon in allWeapons)
+            {
+                Debug.Log($"Loaded weapon: {weapon.ItemName}");
+            }
         }
     }
 

+ 7 - 0
Assets/Scripts/Maze/COPY_PASTE_EXAMPLES.csEXAMPLE.meta

@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 1aa5029ffffac5c4dbec67dfabd69979
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 0 - 2
Assets/Scripts/Maze/Combat/COPY_PASTE_EXAMPLES.cs.meta

@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 4cee38a05c8c8cf4a8af1e1620e1f5d3

+ 17 - 1
Assets/Scripts/Maze/Combat/CharacterSheet.cs

@@ -9,6 +9,8 @@ namespace MazeGen
     /// </summary>
     public class CharacterSheet
     {
+        private string name;
+        private string gameobjectName;
         private CharacterStats _baseStats;
         private Weapon _equippedWeapon;
         private Armor _equippedArmor;
@@ -21,14 +23,28 @@ namespace MazeGen
         private Dictionary<string, float> _speedBonuses = new Dictionary<string, float>();
         private Dictionary<string, float> _healthBonuses = new Dictionary<string, float>();
 
+        public string Name { get => name; set => name = value; }
         public CharacterStats Stats => _baseStats;
         public Weapon EquippedWeapon => _equippedWeapon;
         public Armor EquippedArmor => _equippedArmor;
         public IReadOnlyList<Item> Inventory => _inventory.AsReadOnly();
 
-        public CharacterSheet(CharacterStats baseStats)
+        public CharacterSheet(GameObject go, CharacterStats baseStats)
         {
             _baseStats = baseStats;
+            if (go != null && go.tag.Equals("Agent"))
+            {
+                Name = NameGenerator.GenerateRandomName();
+            }
+            else if (go != null)
+            {
+                Name = go.name;
+            }
+            else
+            {
+                // Fallback for ScriptableObjects (like Monster templates) that don't have GameObjects
+                Name = "Unknown";
+            }
         }
 
         /// <summary>

+ 518 - 0
Assets/Scripts/Maze/Combat/CombatDirector.cs

@@ -0,0 +1,518 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MazeGen
+{
+    /// <summary>
+    /// Owns the whole fight lifecycle: declaration, the join window, sneak attempts, resolution,
+    /// and pushing the results back into the maze.
+    ///
+    /// Lifecycle of a fight:
+    ///  1. An agent decides (or is forced) to fight -> <see cref="DeclareFight"/>. Every agent
+    ///     already standing in that room is dragged in too; they were there when it kicked off.
+    ///  2. A join window (default 5s, <see cref="JoinWindowSeconds"/>) counts down on
+    ///     SimulationClock time. During it, agents walking into the room can join (better odds)
+    ///     or attempt to sneak past - a failed sneak forces them in. Agents elsewhere in the maze
+    ///     cannot join.
+    ///  3. When the window expires the fight resolves via <see cref="CombatResolver"/>, either
+    ///     log-only or replayed on the arena grid.
+    ///  4. Results are applied to the real character sheets, so the room afterwards differs only
+    ///     by who is dead and how hurt the survivors are.
+    ///
+    /// Committed agents are marked <see cref="MazeAgent.IsEngaged"/>, which parks their brain -
+    /// they can't wander off mid-muster.
+    /// </summary>
+    [DisallowMultipleComponent]
+    public class CombatDirector : MonoBehaviour
+    {
+        /// <summary>Outcome of an agent arriving in a room where a fight is already mustering.</summary>
+        public enum ArrivalDecision
+        {
+            NoFight,
+            Joined,
+            SneakedPast,
+            SneakFailedAndJoined
+        }
+
+        private const string ArenaHoldKey = "watching a fight";
+        private const int MaxLogLines = 600;
+        private const int MaxRecentFights = 12;
+
+        [Header("Join window")]
+        [Tooltip("Seconds between a fight being declared and it actually starting. Other agents can arrive and pile in (or try to sneak past) during this window.")]
+        [SerializeField, Min(0f)] private float joinWindowSeconds = 5f;
+
+        [Header("Observation")]
+        [Tooltip("Freeze the whole simulation the moment a fight is declared, so you can zoom in and decide whether to watch it. The join window is frozen too - resume to let agents converge.")]
+        [SerializeField] private bool pauseOnFightDeclared = true;
+        [Tooltip("How a fight resolves when its join window runs out on its own: Simulate = log only, Watch = play it out on the arena grid.")]
+        [SerializeField] private CombatViewMode timeoutResolution = CombatViewMode.Simulate;
+
+        [Header("Resolution")]
+        [Tooltip("Rounds after which an unresolved fight is called a stalemate and both sides disengage.")]
+        [SerializeField, Min(1)] private int maxRounds = 40;
+
+        [Header("Sneaking")]
+        [Tooltip("Baseline chance an agent slips past a fight/monsters without being pulled in.")]
+        [SerializeField, Range(0f, 1f)] private float sneakBaseChance = 0.55f;
+        [Tooltip("How much each point of speed advantage over the monsters' average adds to the sneak chance.")]
+        [SerializeField] private float sneakSpeedWeight = 0.02f;
+        [Tooltip("Sneak chance penalty per hostile in the room beyond the first - more eyes, harder to slip by.")]
+        [SerializeField] private float sneakPenaltyPerMonster = 0.08f;
+
+        private readonly List<PendingFight> _active = new List<PendingFight>();
+        private readonly List<PendingFight> _recent = new List<PendingFight>();
+        private readonly List<MazeAgentController> _agents = new List<MazeAgentController>();
+        private readonly Dictionary<MazeAgent, Color> _agentColors = new Dictionary<MazeAgent, Color>();
+        private readonly List<string> _log = new List<string>();
+
+        private BattleArenaView _arena;
+        private int _nextFightId = 1;
+
+        public float JoinWindowSeconds => joinWindowSeconds;
+        public IReadOnlyList<PendingFight> ActiveFights => _active;
+        public IReadOnlyList<PendingFight> RecentFights => _recent;
+        public IReadOnlyList<string> CombatLog => _log;
+
+        /// <summary>True while an arena playback is on screen; the maze HUD hides itself then.</summary>
+        public bool IsWatching { get; private set; }
+
+        public bool PauseOnFightDeclared
+        {
+            get => pauseOnFightDeclared;
+            set => pauseOnFightDeclared = value;
+        }
+
+        public CombatViewMode TimeoutResolution
+        {
+            get => timeoutResolution;
+            set => timeoutResolution = value;
+        }
+
+        public event Action<PendingFight> FightDeclared;
+        public event Action<PendingFight> FightResolved;
+
+        private void Awake()
+        {
+            EnsureArena();
+        }
+
+        /// <summary>
+        /// Resolved lazily (and added if missing) because this component may itself have been
+        /// added at runtime by MazeSceneBuilder, after its own Awake would have run.
+        /// </summary>
+        private BattleArenaView EnsureArena()
+        {
+            if (_arena == null)
+                _arena = GetComponent<BattleArenaView>() ?? gameObject.AddComponent<BattleArenaView>();
+            return _arena;
+        }
+
+        /// <summary>Agents must register so the director can find who else is standing in a room.</summary>
+        public void RegisterAgent(MazeAgentController controller, Color color)
+        {
+            if (controller == null || _agents.Contains(controller)) return;
+            _agents.Add(controller);
+            if (controller.Agent != null)
+                _agentColors[controller.Agent] = color;
+        }
+
+        private void Update()
+        {
+            if (IsWatching) return;
+
+            float dt = SimulationClock.DeltaTime;
+
+            // Backwards: resolving/cancelling removes from this list.
+            for (int i = _active.Count - 1; i >= 0; i--)
+            {
+                PendingFight fight = _active[i];
+                if (fight.State != FightState.Mustering) continue;
+
+                DropUnavailableParticipants(fight);
+
+                if (fight.Agents.Count == 0)
+                {
+                    Cancel(fight, "every committed agent died or left before it started");
+                    continue;
+                }
+                if (!fight.Encounter.HasLivingMonsters)
+                {
+                    Cancel(fight, "the monsters were already dead");
+                    continue;
+                }
+
+                if (dt <= 0f) continue; // paused - the join window is frozen too
+
+                fight.JoinTimeRemaining -= dt;
+                if (fight.JoinTimeRemaining <= 0f)
+                    BeginResolution(fight, fight.PreferredResolution ?? timeoutResolution);
+            }
+        }
+
+        // ---------------------------------------------------------------- declaring & joining
+
+        /// <summary>
+        /// An agent commits to fighting whatever is in <paramref name="room"/>. If a fight is
+        /// already mustering there this just joins it instead of starting a second one.
+        /// Returns null if there's nothing to fight.
+        /// </summary>
+        public PendingFight DeclareFight(Room room, MazeAgent declarer, string trigger)
+        {
+            if (room?.Encounter == null || !room.Encounter.HasLivingMonsters) return null;
+            if (declarer == null || declarer.IsDead) return null;
+
+            PendingFight existing = FindMusteringFight(room);
+            if (existing != null)
+            {
+                if (!existing.Contains(declarer))
+                    Commit(existing, declarer, trigger);
+                return existing;
+            }
+
+            var fight = new PendingFight(_nextFightId++, room, room.Encounter)
+            {
+                JoinTimeRemaining = joinWindowSeconds,
+                Trigger = trigger
+            };
+            _active.Add(fight);
+
+            Commit(fight, declarer, trigger);
+
+            // Anyone else already in this room is in it whether they like it or not.
+            foreach (var controller in _agents)
+            {
+                MazeAgent other = controller.Agent;
+                if (other == null || other == declarer) continue;
+                if (!IsAvailable(other) || other.CurrentRoom != room) continue;
+                Commit(fight, other, "was already in the room when the fight broke out");
+            }
+
+            Log($"{fight.Describe()} - declared by {declarer.CharacterSheet.Name} ({trigger}). Starts in {joinWindowSeconds:0.0}s.", echoToConsole: false);
+
+            if (pauseOnFightDeclared)
+                SimulationClock.PushHold(HoldKeyFor(fight));
+
+            FightDeclared?.Invoke(fight);
+            return fight;
+        }
+
+        /// <summary>
+        /// Called by an agent's brain when it finds itself in a room where a fight is mustering.
+        /// <paramref name="wantsToSneak"/> is the agent's own call; a failed sneak drags it in.
+        /// </summary>
+        public ArrivalDecision ResolveArrival(MazeAgent agent, bool wantsToSneak)
+        {
+            if (agent == null || agent.CurrentRoom == null) return ArrivalDecision.NoFight;
+
+            PendingFight fight = FindMusteringFight(agent.CurrentRoom);
+            if (fight == null) return ArrivalDecision.NoFight;
+            if (fight.Contains(agent)) return ArrivalDecision.Joined;
+
+            if (!wantsToSneak)
+            {
+                Commit(fight, agent, "arrived and joined the fight");
+                Log($"{agent.CharacterSheet.Name} joins fight #{fight.Id} - now {fight.Agents.Count} agent(s) vs {fight.Encounter.AliveMonsterCount} monster(s).", echoToConsole: false);
+                return ArrivalDecision.Joined;
+            }
+
+            float odds = SneakChance(agent, fight.Encounter);
+            if (UnityEngine.Random.value < odds)
+            {
+                agent.Thoughts.Add(ThoughtKind.Sneak, $"Slipped past the fight in room {fight.Room.Id} ({odds:P0} odds) - not my problem.");
+                Log($"{agent.CharacterSheet.Name} sneaks past fight #{fight.Id} ({odds:P0} odds).", echoToConsole: false);
+                return ArrivalDecision.SneakedPast;
+            }
+
+            Commit(fight, agent, $"was spotted sneaking past ({odds:P0} odds) and forced in");
+            Log($"{agent.CharacterSheet.Name} is spotted sneaking past fight #{fight.Id} ({odds:P0} odds) and is forced into it.", echoToConsole: false);
+            return ArrivalDecision.SneakFailedAndJoined;
+        }
+
+        /// <summary>
+        /// Sneak attempt against monsters in a room where no fight has been declared yet (an
+        /// agent that walked in, didn't like the odds, and wants to leave quietly). A failure
+        /// leaves it to the caller to declare the fight it just got caught in.
+        /// </summary>
+        public bool TrySneakOut(MazeAgent agent, out float odds)
+        {
+            odds = 0f;
+            MonsterEncounter encounter = agent?.CurrentRoom?.Encounter;
+            if (encounter == null || !encounter.HasLivingMonsters) return true;
+
+            odds = SneakChance(agent, encounter);
+            return UnityEngine.Random.value < odds;
+        }
+
+        /// <summary>
+        /// Faster than the monsters and few of them = good odds. Clamped so sneaking is never a
+        /// certainty either way.
+        /// </summary>
+        public float SneakChance(MazeAgent agent, MonsterEncounter encounter)
+        {
+            if (encounter == null || !encounter.HasLivingMonsters) return 1f;
+
+            float totalSpeed = 0f;
+            int alive = 0;
+            foreach (var monster in encounter.Monsters)
+            {
+                if (monster.IsDefeated) continue;
+                totalSpeed += monster.CharacterSheet.GetEffectiveSpeed();
+                alive++;
+            }
+            if (alive == 0) return 1f;
+
+            float averageSpeed = totalSpeed / alive;
+            float chance = sneakBaseChance
+                + sneakSpeedWeight * (agent.CharacterSheet.GetEffectiveSpeed() - averageSpeed)
+                - sneakPenaltyPerMonster * (alive - 1);
+
+            return Mathf.Clamp(chance, 0.05f, 0.95f);
+        }
+
+        public PendingFight FindMusteringFight(Room room)
+        {
+            if (room == null) return null;
+            foreach (var fight in _active)
+                if (fight.State == FightState.Mustering && fight.Room.Id == room.Id)
+                    return fight;
+            return null;
+        }
+
+        private void Commit(PendingFight fight, MazeAgent agent, string reason)
+        {
+            if (fight.Contains(agent)) return;
+
+            fight.Agents.Add(agent);
+            agent.IsEngaged = true;
+            agent.Thoughts.Add(ThoughtKind.Fight,
+                $"In fight #{fight.Id} (room {fight.Room.Id}) - {reason}. {fight.Agents.Count} of us vs {fight.Encounter.AliveMonsterCount} hostile(s), starting in {Mathf.Max(0f, fight.JoinTimeRemaining):0.0}s.");
+        }
+
+        /// <summary>Drops committed agents that died (elsewhere) or finished before the fight began.</summary>
+        private void DropUnavailableParticipants(PendingFight fight)
+        {
+            for (int i = fight.Agents.Count - 1; i >= 0; i--)
+            {
+                MazeAgent agent = fight.Agents[i];
+                if (agent == null || agent.IsDead || agent.IsFinished)
+                {
+                    if (agent != null) agent.IsEngaged = false;
+                    fight.Agents.RemoveAt(i);
+                }
+            }
+        }
+
+        // ---------------------------------------------------------------- observer controls
+
+        /// <summary>Let a paused fight's join window continue counting down.</summary>
+        public void ResumeMuster(PendingFight fight)
+        {
+            if (fight == null) return;
+            SimulationClock.PopHold(HoldKeyFor(fight));
+        }
+
+        public void ResumeAllMusters()
+        {
+            foreach (var fight in _active)
+                SimulationClock.PopHold(HoldKeyFor(fight));
+        }
+
+        /// <summary>
+        /// Sets the observer's preference for how this fight resolves once its join window runs
+        /// out. This is a preference only, NOT a trigger - the join window always has to elapse
+        /// on its own first (so other agents keep getting their full chance to pile in or sneak
+        /// past), even if the simulation is currently paused waiting on the observer.
+        /// </summary>
+        public void PreferSimulate(PendingFight fight) => SetPreferredResolution(fight, CombatViewMode.Simulate);
+
+        /// <summary>See <see cref="PreferSimulate"/> - same, but plays out on the arena grid.</summary>
+        public void PreferWatch(PendingFight fight) => SetPreferredResolution(fight, CombatViewMode.Watch);
+
+        private void SetPreferredResolution(PendingFight fight, CombatViewMode mode)
+        {
+            if (fight == null || fight.State != FightState.Mustering) return;
+            if (fight.PreferredResolution == mode) return;
+
+            fight.PreferredResolution = mode;
+            Log($"Fight #{fight.Id} in room {fight.Room.Id} will be {(mode == CombatViewMode.Watch ? "watched" : "logged only")} when its join window ends.", echoToConsole: false);
+        }
+
+        // ---------------------------------------------------------------- resolution
+
+        private void BeginResolution(PendingFight fight, CombatViewMode mode)
+        {
+            if (fight == null || fight.State != FightState.Mustering) return;
+
+            SimulationClock.PopHold(HoldKeyFor(fight));
+            DropUnavailableParticipants(fight);
+
+            if (fight.Agents.Count == 0)
+            {
+                Cancel(fight, "no agents left to fight");
+                return;
+            }
+            if (!fight.Encounter.HasLivingMonsters)
+            {
+                Cancel(fight, "no monsters left to fight");
+                return;
+            }
+
+            fight.State = FightState.Resolving;
+            fight.ResolvedAs = mode;
+
+            List<Combatant> combatants = BuildCombatants(fight);
+            fight.Result = CombatResolver.Resolve(combatants, maxRounds);
+
+            BattleArenaView arena = mode == CombatViewMode.Watch ? EnsureArena() : null;
+            if (arena != null)
+            {
+                IsWatching = true;
+                SimulationClock.PushHold(ArenaHoldKey);
+                StartCoroutine(WatchRoutine(arena, fight, combatants));
+            }
+            else
+            {
+                ApplyResult(fight, combatants);
+            }
+        }
+
+        private IEnumerator WatchRoutine(BattleArenaView arena, PendingFight fight, List<Combatant> combatants)
+        {
+            // Playback is pure animation over an already-decided event list, so however long the
+            // observer takes (or if they skip), the outcome below is unchanged.
+            yield return arena.Play(fight, combatants);
+
+            SimulationClock.PopHold(ArenaHoldKey);
+            IsWatching = false;
+            ApplyResult(fight, combatants);
+        }
+
+        /// <summary>
+        /// Pushes the decided outcome back into maze state: dead agents die, survivors are
+        /// released to keep exploring, everyone involved gets the blow-by-blow in their thought
+        /// log. Monster/agent health was already mutated in place by the resolver.
+        /// </summary>
+        private void ApplyResult(PendingFight fight, List<Combatant> combatants)
+        {
+            BattleResult result = fight.Result;
+
+            foreach (var e in result.Events)
+                RecordEvent(result, e);
+
+            foreach (var combatant in combatants)
+            {
+                if (!combatant.IsAgent) continue;
+
+                MazeAgent agent = combatant.Agent;
+                agent.IsEngaged = false;
+
+                if (!combatant.IsAlive)
+                {
+                    agent.Thoughts.Add(ThoughtKind.Goal, $"Killed in fight #{fight.Id}, room {fight.Room.Id}.");
+                    agent.Die();
+                    continue;
+                }
+
+                agent.Thoughts.Add(ThoughtKind.Outcome,
+                    $"Fight #{fight.Id} over: {result.Summary} I'm on {combatant.Sheet.Stats.CurrentHealth:0.#}/{combatant.MaxHealth:0.#} hp.");
+
+                // A stalemate would otherwise have the agent immediately re-declare the same
+                // fight forever, so remember not to pick this one again.
+                if (result.Outcome == BattleOutcome.Stalemate)
+                {
+                    agent.NoteFledFrom(fight.Room.Id);
+                    agent.Thoughts.Add(ThoughtKind.Avoid, $"Couldn't finish them off in room {fight.Room.Id} - disengaging and not trying that again.");
+                }
+            }
+
+            fight.State = FightState.Resolved;
+            _active.Remove(fight);
+            Remember(fight);
+
+            Log($"Fight #{fight.Id} ({(fight.ResolvedAs == CombatViewMode.Watch ? "watched" : "simulated")}) in room {fight.Room.Id}: {result.Summary}", echoToConsole: false);
+            FightResolved?.Invoke(fight);
+        }
+
+        private void Cancel(PendingFight fight, string why)
+        {
+            SimulationClock.PopHold(HoldKeyFor(fight));
+            foreach (var agent in fight.Agents)
+                if (agent != null) agent.IsEngaged = false;
+
+            fight.State = FightState.Cancelled;
+            _active.Remove(fight);
+            Remember(fight);
+
+            Log($"Fight #{fight.Id} in room {fight.Room.Id} called off - {why}.", echoToConsole: false);
+            FightResolved?.Invoke(fight);
+        }
+
+        private List<Combatant> BuildCombatants(PendingFight fight)
+        {
+            var combatants = new List<Combatant>();
+            int id = 0;
+
+            foreach (var agent in fight.Agents)
+            {
+                Color color = _agentColors.TryGetValue(agent, out var c) ? c : Color.white;
+                combatants.Add(Combatant.ForAgent(id++, agent, color));
+            }
+
+            foreach (var monster in fight.Encounter.Monsters)
+            {
+                if (monster.IsDefeated) continue; // already dead from an earlier fight in this room
+                combatants.Add(Combatant.ForMonster(id++, monster));
+            }
+
+            return combatants;
+        }
+
+        /// <summary>Mirrors one combat event into the global log and the thought logs of the agents it involved.</summary>
+        private void RecordEvent(BattleResult result, CombatEvent e)
+        {
+            // Blow-by-blow lines stay in the in-memory log/HUD only. A long fight between big
+            // groups emits hundreds of events, and echoing every one to Debug.Log would drown
+            // the Console and cost real frame time.
+            Log(e.Text, echoToConsole: false);
+
+            if (e.Kind == CombatEventKind.RoundStart) return;
+
+            AddCombatThought(result, e.ActorId, e.Text);
+            if (e.TargetId != e.ActorId)
+                AddCombatThought(result, e.TargetId, e.Text);
+        }
+
+        private void AddCombatThought(BattleResult result, int combatantId, string text)
+        {
+            if (combatantId < 0 || result.Combatants == null || combatantId >= result.Combatants.Count) return;
+
+            Combatant combatant = result.Combatants[combatantId];
+            if (combatant.IsAgent)
+                combatant.Agent.Thoughts.Add(ThoughtKind.Combat, text);
+        }
+
+        private void Remember(PendingFight fight)
+        {
+            _recent.Insert(0, fight);
+            if (_recent.Count > MaxRecentFights)
+                _recent.RemoveAt(_recent.Count - 1);
+        }
+
+        private void Log(string line, bool echoToConsole = true)
+        {
+            _log.Add(line);
+            if (_log.Count > MaxLogLines)
+                _log.RemoveAt(0);
+            if (echoToConsole)
+                Debug.Log($"[Combat] {line}");
+        }
+
+        private static bool IsAvailable(MazeAgent agent)
+            => agent != null && !agent.IsDead && !agent.IsFinished && !agent.IsEngaged;
+
+        private static string HoldKeyFor(PendingFight fight) => $"fight #{fight.Id} in room {fight.Room.Id}";
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Combat/CombatDirector.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9fd6a64c71db20745968a20255a753ac

+ 82 - 0
Assets/Scripts/Maze/Combat/CombatEvent.cs

@@ -0,0 +1,82 @@
+using System.Collections.Generic;
+
+namespace MazeGen
+{
+    public enum CombatEventKind
+    {
+        FightStart,
+        RoundStart,
+        Hit,
+        Miss,
+        Death,
+        FightEnd
+    }
+
+    /// <summary>
+    /// One atomic thing that happened during a battle. A resolved battle is nothing but an
+    /// ordered list of these.
+    ///
+    /// This is the seam that lets "just log it" and "watch it on a grid" share one outcome: the
+    /// resolver runs to completion up front and emits this list, log-only mode prints it, and the
+    /// arena replays the exact same list as animation. The view therefore cannot desync from the
+    /// simulation, and switching modes can never change who wins.
+    /// </summary>
+    public class CombatEvent
+    {
+        public CombatEventKind Kind;
+        public int Round;
+
+        /// <summary>Combatant id of whoever acted, or -1 for battle/round-level events.</summary>
+        public int ActorId = -1;
+
+        /// <summary>Combatant id of whoever was acted upon, or -1.</summary>
+        public int TargetId = -1;
+
+        /// <summary>Damage dealt (Hit), or the target's remaining health (Death).</summary>
+        public float Amount;
+
+        /// <summary>Pre-rendered log line, so the log/thought/feed surfaces don't each re-describe it.</summary>
+        public string Text;
+    }
+
+    public enum BattleOutcome
+    {
+        AgentsVictory,
+        MonstersVictory,
+        Stalemate
+    }
+
+    /// <summary>The complete, already-decided result of one battle.</summary>
+    public class BattleResult
+    {
+        public BattleOutcome Outcome;
+        public int Rounds;
+        public readonly List<CombatEvent> Events = new List<CombatEvent>();
+        public IReadOnlyList<Combatant> Combatants;
+
+        /// <summary>Agents who were reduced to 0 health in this battle.</summary>
+        public readonly List<Combatant> FallenAgents = new List<Combatant>();
+
+        /// <summary>Monsters killed in this battle.</summary>
+        public readonly List<Combatant> FallenMonsters = new List<Combatant>();
+
+        /// <summary>Agents still standing when the battle ended.</summary>
+        public readonly List<Combatant> SurvivingAgents = new List<Combatant>();
+
+        public string Summary
+        {
+            get
+            {
+                switch (Outcome)
+                {
+                    case BattleOutcome.AgentsVictory:
+                        return $"Agents won in {Rounds} round(s) - {FallenMonsters.Count} monster(s) killed, {FallenAgents.Count} agent(s) lost.";
+                    case BattleOutcome.MonstersVictory:
+                        return $"Monsters won in {Rounds} round(s) - all {FallenAgents.Count} agent(s) died.";
+                    default:
+                        return $"Stalemate after {Rounds} round(s) - both sides still standing, everyone disengages.";
+                }
+            }
+        }
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Combat/CombatEvent.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 916b0d4de7eff6249aa5586263563333

+ 120 - 136
Assets/Scripts/Maze/Combat/CombatResolver.cs

@@ -4,192 +4,176 @@ using UnityEngine;
 namespace MazeGen
 {
     /// <summary>
-    /// Skeleton for resolving combat between an agent and monsters in a room.
-    /// This is NOT a full implementation - you'll need to expand this with:
-    /// - Turn order calculation
-    /// - Action resolution (attack, defend, ability)
-    /// - Damage calculation
-    /// - Status effects
-    /// - Ability cooldowns
+    /// Resolves a battle between a group of agents and a group of monsters, all in one room.
     ///
-    /// This provides just the structure and basic helper functions.
+    /// Runs to completion synchronously and returns the whole thing as an ordered
+    /// <see cref="CombatEvent"/> list (see <see cref="BattleResult"/>). 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.
     /// </summary>
-    public class CombatResolver
+    public static class CombatResolver
     {
-        public enum CombatOutcome
-        {
-            AgentVictory,
-            AgentDefeated,
-            Fleeing // Agent escaped before resolution
-        }
+        // 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;
 
         /// <summary>
-        /// Simulate combat between an agent and the monsters in this room.
-        /// Returns the outcome. You'll need to expand this significantly for a real system.
+        /// Fights it out until one side is wiped or <paramref name="maxRounds"/> is exhausted
+        /// (which yields a stalemate - both sides disengage rather than looping forever).
         /// </summary>
-        public static CombatOutcome ResolveCombat(MazeAgent agent, MonsterEncounter encounter)
+        public static BattleResult Resolve(IReadOnlyList<Combatant> combatants, int maxRounds = 40)
         {
-            if (encounter == null || !encounter.HasLivingMonsters)
-                return CombatOutcome.AgentVictory; // No fight needed
+            var result = new BattleResult { Combatants = combatants };
 
-            // TODO: Implement full combat loop
-            // 1. Build turn order from Speed stats
-            var turnOrder = BuildTurnOrder(agent, encounter);
+            int agentCount = CountAlive(combatants, Combatant.TeamAgents);
+            int monsterCount = CountAlive(combatants, Combatant.TeamMonsters);
 
-            // 2. Simulate turns until victory/defeat
-            int maxRounds = 100; // Prevent infinite loops during development
-            int round = 0;
+            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);
 
-            while (round < maxRounds && agent.CharacterSheet.Stats.IsAlive && encounter.HasLivingMonsters)
+            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} --");
 
-                // Execute each turn in order
-                foreach (var turn in turnOrder)
+                foreach (var actor in order)
                 {
-                    if (turn.IsAgent)
-                    {
-                        ExecuteAgentTurn(agent, encounter);
-                    }
-                    else
-                    {
-                        ExecuteMonsterTurn(turn.MonsterIndex, agent, encounter);
-                    }
-
-                    // Check win conditions each turn
-                    if (!agent.CharacterSheet.Stats.IsAlive)
-                        return CombatOutcome.AgentDefeated;
-                    if (!encounter.HasLivingMonsters)
-                        return CombatOutcome.AgentVictory;
+                    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;
                 }
             }
 
-            if (agent.CharacterSheet.Stats.IsAlive && !encounter.HasLivingMonsters)
-                return CombatOutcome.AgentVictory;
+            result.Rounds = round;
+            Tally(result, combatants);
 
-            return CombatOutcome.AgentDefeated;
+            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;
         }
 
         /// <summary>
-        /// Determine turn order based on Speed stats.
-        /// Higher speed = earlier in turn order.
+        /// Single attack: roll to hit, then damage attacker's effective attack against the
+        /// target's effective defense.
         /// </summary>
-        private static List<(bool IsAgent, int MonsterIndex)> BuildTurnOrder(MazeAgent agent, MonsterEncounter encounter)
+        private static void TakeSwing(BattleResult result, int round, Combatant actor, Combatant target)
         {
-            var order = new List<(bool, int)>();
-
-            // Agent's speed
-            float agentInitiative = agent.CharacterSheet.GetEffectiveSpeed();
-            order.Add((true, -1)); // IsAgent=true, MonsterIndex unused
+            float hitChance = Mathf.Clamp(
+                BaseHitChance + HitChancePerSpeedPoint * (actor.Sheet.GetEffectiveSpeed() - target.Sheet.GetEffectiveSpeed()),
+                MinHitChance, MaxHitChance);
 
-            // Each monster's speed
-            for (int i = 0; i < encounter.Monsters.Count; i++)
+            if (Random.value > hitChance)
             {
-                if (!encounter.Monsters[i].IsDefeated)
-                {
-                    float monsterInitiative = encounter.Monsters[i].CharacterSheet.GetEffectiveSpeed();
-                    order.Add((false, i));
-                }
+                Emit(result, CombatEventKind.Miss, round, actor.Id, target.Id, 0f,
+                    $"{actor.Name} swings at {target.Name} and misses ({hitChance:P0} to hit).");
+                return;
             }
 
-            // Sort by initiative (higher = first)
-            // TODO: Implement proper sorting by speed
-            // order.Sort((a, b) => ...)
+            // 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));
 
-            return order;
-        }
+            target.Sheet.Stats.TakeDamage(damage);
 
-        /// <summary>
-        /// Execute the agent's turn. Pick a target and attack.
-        /// This is a stub - implement your action selection here.
-        /// </summary>
-        private static void ExecuteAgentTurn(MazeAgent agent, MonsterEncounter encounter)
-        {
-            // TODO: Implement agent decision (which monster to target, what ability to use)
+            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).");
 
-            // For now, just attack the first alive monster
-            for (int i = 0; i < encounter.Monsters.Count; i++)
+            if (!target.IsAlive)
             {
-                if (!encounter.Monsters[i].IsDefeated)
-                {
-                    AttackMonster(agent, encounter, i);
-                    break;
-                }
+                Emit(result, CombatEventKind.Death, round, actor.Id, target.Id, 0f,
+                    $"{target.Name} is killed by {actor.Name}.");
             }
         }
 
-        /// <summary>
-        /// Execute one monster's turn. They attack the agent.
-        /// This is a stub - add AI/decision logic here.
-        /// </summary>
-        private static void ExecuteMonsterTurn(int monsterIndex, MazeAgent agent, MonsterEncounter encounter)
+        /// <summary>Fastest first; ties broken randomly so identical monsters don't always act in spawn order.</summary>
+        private static List<Combatant> BuildTurnOrder(IReadOnlyList<Combatant> combatants)
         {
-            if (monsterIndex < 0 || monsterIndex >= encounter.Monsters.Count)
-                return;
+            var order = new List<Combatant>(combatants);
+            var initiative = new Dictionary<int, float>(order.Count);
+            foreach (var c in order)
+                initiative[c.Id] = c.Sheet.GetEffectiveSpeed() + Random.Range(0f, 4f);
 
-            var monster = encounter.Monsters[monsterIndex];
-            if (monster.IsDefeated)
-                return;
-
-            // TODO: Implement monster AI (target selection, ability choice, etc.)
-
-            // For now, just attack the agent
-            AttackAgent(monster, agent);
+            order.Sort((a, b) => initiative[b.Id].CompareTo(initiative[a.Id]));
+            return order;
         }
 
-        /// <summary>
-        /// Calculate and apply damage when agent attacks a monster.
-        /// Stub - expand with damage types, abilities, modifiers, etc.
-        /// </summary>
-        private static void AttackMonster(MazeAgent agent, MonsterEncounter encounter, int targetIndex)
+        /// <summary>Picks a random living enemy. Random rather than focus-fire so groups spread damage.</summary>
+        private static Combatant PickTarget(IReadOnlyList<Combatant> combatants, Combatant actor)
         {
-            var target = encounter.Monsters[targetIndex];
+            var candidates = new List<Combatant>();
+            foreach (var c in combatants)
+                if (c.Team != actor.Team && c.IsAlive)
+                    candidates.Add(c);
 
-            // Simple attack: agent's attack vs target's defense
-            float baseDamage = agent.CharacterSheet.GetEffectiveAttack();
-            float damage = target.CharacterSheet.Stats.CalculateDamageReduction(baseDamage);
-
-            Debug.Log($"{agent.Name} attacks {target.Template.MonsterName} for {damage} damage");
-
-            target.CharacterSheet.Stats.TakeDamage(damage);
+            return candidates.Count == 0 ? null : candidates[Random.Range(0, candidates.Count)];
+        }
 
-            if (target.IsDefeated)
+        private static void Tally(BattleResult result, IReadOnlyList<Combatant> combatants)
+        {
+            foreach (var c in combatants)
             {
-                Debug.Log($"{target.Template.MonsterName} defeated!");
-                // TODO: Award loot to agent
+                if (c.IsAgent)
+                {
+                    if (c.IsAlive) result.SurvivingAgents.Add(c);
+                    else result.FallenAgents.Add(c);
+                }
+                else if (!c.IsAlive)
+                {
+                    result.FallenMonsters.Add(c);
+                }
             }
         }
 
-        /// <summary>
-        /// Calculate and apply damage when monster attacks agent.
-        /// Stub - expand similarly to AttackMonster.
-        /// </summary>
-        private static void AttackAgent(MonsterEncounter.MonsterInstance attacker, MazeAgent target)
+        private static void Emit(BattleResult result, CombatEventKind kind, int round, int actorId, int targetId, float amount, string text)
         {
-            float baseDamage = attacker.CharacterSheet.GetEffectiveAttack();
-            float damage = target.CharacterSheet.Stats.CalculateDamageReduction(baseDamage);
-
-            Debug.Log($"{attacker.Template.MonsterName} attacks {target.Name} for {damage} damage");
-
-            target.CharacterSheet.Stats.TakeDamage(damage);
-
-            if (!target.CharacterSheet.Stats.IsAlive)
+            result.Events.Add(new CombatEvent
             {
-                Debug.Log($"{target.Name} defeated!");
-                target.Die();
-            }
+                Kind = kind,
+                Round = round,
+                ActorId = actorId,
+                TargetId = targetId,
+                Amount = amount,
+                Text = text
+            });
         }
 
-        /// <summary>
-        /// Award loot from a defeated monster to the agent.
-        /// Stub - implement inventory/pickup logic.
-        /// </summary>
-        private static void AwardLoot(MonsterEncounter.MonsterInstance monster, MazeAgent agent)
+        private static int CountAlive(IReadOnlyList<Combatant> combatants, int team)
         {
-            // TODO: Implement loot pickup
-            // var loot = monster.Template.GetRandomLoot();
-            // if (loot != null)
-            //     agent.CharacterSheet.AddToInventory(loot);
+            int count = 0;
+            foreach (var c in combatants)
+                if (c.Team == team && c.IsAlive) count++;
+            return count;
         }
+
+        private static bool HasAlive(IReadOnlyList<Combatant> combatants, int team) => CountAlive(combatants, team) > 0;
     }
 }

+ 64 - 0
Assets/Scripts/Maze/Combat/Combatant.cs

@@ -0,0 +1,64 @@
+using UnityEngine;
+
+namespace MazeGen
+{
+    /// <summary>
+    /// One participant in a battle - either an agent or a monster instance - behind a single
+    /// interface so <see cref="CombatResolver"/> doesn't need to branch on which it is.
+    ///
+    /// Wraps the *live* <see cref="CharacterSheet"/>, not a copy: resolving a fight mutates real
+    /// agent and monster health, which is exactly what "only the result of the fight has changed
+    /// for that room" means. <see cref="StartHealth"/> is snapshotted before resolution purely so
+    /// the arena playback can animate health draining down from where it actually started.
+    /// </summary>
+    public class Combatant
+    {
+        public const int TeamAgents = 0;
+        public const int TeamMonsters = 1;
+
+        /// <summary>Index of this combatant within its own battle. Combat events refer to these.</summary>
+        public readonly int Id;
+        public readonly int Team;
+        public readonly string Name;
+        public readonly Color Color;
+
+        /// <summary>Set for agent combatants, null for monsters.</summary>
+        public readonly MazeAgent Agent;
+
+        /// <summary>Set for monster combatants, null for agents.</summary>
+        public readonly MonsterEncounter.MonsterInstance Monster;
+
+        public readonly CharacterSheet Sheet;
+
+        /// <summary>Health at the moment the battle was resolved, for playback purposes.</summary>
+        public readonly float StartHealth;
+
+        public bool IsAgent => Agent != null;
+        public bool IsAlive => Sheet.Stats.IsAlive;
+
+        /// <summary>
+        /// Base max health, not GetEffectiveMaxHealth(): CharacterStats.CurrentHealth is clamped
+        /// to the base value, so using the effective one here would give health bars that can
+        /// never fill up whenever equipment grants a health bonus.
+        /// </summary>
+        public float MaxHealth => Mathf.Max(1f, Sheet.Stats.MaxHealth);
+
+        private Combatant(int id, int team, string name, Color color, MazeAgent agent, MonsterEncounter.MonsterInstance monster, CharacterSheet sheet)
+        {
+            Id = id;
+            Team = team;
+            Name = name;
+            Color = color;
+            Agent = agent;
+            Monster = monster;
+            Sheet = sheet;
+            StartHealth = sheet.Stats.CurrentHealth;
+        }
+
+        public static Combatant ForAgent(int id, MazeAgent agent, Color color)
+            => new Combatant(id, TeamAgents, agent.CharacterSheet.Name, color, agent, null, agent.CharacterSheet);
+
+        public static Combatant ForMonster(int id, MonsterEncounter.MonsterInstance monster)
+            => new Combatant(id, TeamMonsters, monster.Template.MonsterName, monster.Template.Color, null, monster, monster.CharacterSheet);
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Combat/Combatant.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: cce57b798a622ef4eabeafbc6d0afac6

+ 8 - 0
Assets/Scripts/Maze/Combat/Editor.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: cb78f44089ca81c479e6d3905499ead3
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 78 - 0
Assets/Scripts/Maze/Combat/Editor/ItemCreationMenu.cs

@@ -0,0 +1,78 @@
+#if UNITY_EDITOR
+using UnityEngine;
+using UnityEditor;
+using System.IO;
+using MazeGen;
+
+namespace MazeGen.Editor
+{
+    /// <summary>
+    /// Provides context menu items for creating Weapon and Armor assets in the Unity Editor.
+    /// Assets are created in Resources folders for easy runtime loading via Resources.Load/LoadAll.
+    /// </summary>
+    public class ItemCreationMenu
+    {
+        private const string WeaponsResourcePath = "Assets/Resources/Items/Weapons/";
+        private const string ArmorResourcePath = "Assets/Resources/Items/Armor/";
+        private const string ConsumableResourcePath = "Assets/Resources/Items/Consumables/";
+
+        [MenuItem("Assets/Create/MazeGen/Weapon", priority = 20)]
+        public static void CreateWeapon()
+        {
+            CreateItemAsset<Weapon>(WeaponsResourcePath, "NewWeapon.asset");
+        }
+
+        [MenuItem("Assets/Create/MazeGen/Armor", priority = 20)]
+        public static void CreateArmor()
+        {
+            CreateItemAsset<Armor>(ArmorResourcePath, "NewArmor.asset");
+        }
+
+        [MenuItem("Assets/Create/MazeGen/Consumable", priority = 20)]
+        public static void CreateConsumable()
+        {
+            CreateItemAsset<Consumable>(ConsumableResourcePath, "NewConsumable.asset");
+        }
+
+        /// <summary>
+        /// Generic method to create and save an item asset.
+        /// Creates the resource folder if it doesn't exist, then shows a save dialog.
+        /// </summary>
+        private static void CreateItemAsset<T>(string defaultPath, string defaultName) where T : Item
+        {
+            // Ensure the directory exists
+            if (!Directory.Exists(defaultPath))
+            {
+                Directory.CreateDirectory(defaultPath);
+                AssetDatabase.Refresh();
+            }
+
+            // Show save dialog
+            string savePath = EditorUtility.SaveFilePanelInProject(
+                $"Create New {typeof(T).Name}",
+                defaultName,
+                "asset",
+                "Choose a location and name for the new asset",
+                defaultPath
+            );
+
+            if (string.IsNullOrEmpty(savePath))
+                return; // User cancelled
+
+            // Create the asset
+            T newItem = ScriptableObject.CreateInstance<T>();
+            newItem.name = Path.GetFileNameWithoutExtension(savePath);
+
+            // Save it
+            AssetDatabase.CreateAsset(newItem, savePath);
+            AssetDatabase.SaveAssets();
+            AssetDatabase.Refresh();
+
+            // Select it in the Project window
+            EditorGUIUtility.PingObject(newItem);
+
+            Debug.Log($"Created {typeof(T).Name}: {savePath}");
+        }
+    }
+}
+#endif

+ 2 - 0
Assets/Scripts/Maze/Combat/Editor/ItemCreationMenu.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0427b69019f39ab4e82bdf3b3f838c7d

+ 64 - 0
Assets/Scripts/Maze/Combat/Editor/MonsterCreationMenu.cs

@@ -0,0 +1,64 @@
+#if UNITY_EDITOR
+using UnityEngine;
+using UnityEditor;
+using System.IO;
+using MazeGen;
+
+namespace MazeGen.Editor
+{
+    /// <summary>
+    /// Provides context menu items for creating Weapon and Armor assets in the Unity Editor.
+    /// Assets are created in Resources folders for easy runtime loading via Resources.Load/LoadAll.
+    /// </summary>
+    public class MonsterCreationMenu
+    {
+        private const string MonsterResourcePath = "Assets/Resources/Characters/Monsters/";
+
+        [MenuItem("Assets/Create/MazeGen/Characters/Monster", priority = 20)]
+        public static void CreateMonster()
+        {
+            CreateMonsterAsset<Monster>(MonsterResourcePath, "NewMonster.asset");
+        }
+
+        /// <summary>
+        /// Generic method to create and save an item asset.
+        /// Creates the resource folder if it doesn't exist, then shows a save dialog.
+        /// </summary>
+        private static void CreateMonsterAsset<T>(string defaultPath, string defaultName) where T : Monster
+        {
+            // Ensure the directory exists
+            if (!Directory.Exists(defaultPath))
+            {
+                Directory.CreateDirectory(defaultPath);
+                AssetDatabase.Refresh();
+            }
+
+            // Show save dialog
+            string savePath = EditorUtility.SaveFilePanelInProject(
+                $"Create New {typeof(T).Name}",
+                defaultName,
+                "asset",
+                "Choose a location and name for the new asset",
+                defaultPath
+            );
+
+            if (string.IsNullOrEmpty(savePath))
+                return; // User cancelled
+
+            // Create the asset
+            T newItem = ScriptableObject.CreateInstance<T>();
+            newItem.name = Path.GetFileNameWithoutExtension(savePath);
+
+            // Save it
+            AssetDatabase.CreateAsset(newItem, savePath);
+            AssetDatabase.SaveAssets();
+            AssetDatabase.Refresh();
+
+            // Select it in the Project window
+            EditorGUIUtility.PingObject(newItem);
+
+            Debug.Log($"Created {typeof(T).Name}: {savePath}");
+        }
+    }
+}
+#endif

+ 2 - 0
Assets/Scripts/Maze/Combat/Editor/MonsterCreationMenu.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fafaba026bcaa5549a28ecdda634d088

+ 107 - 0
Assets/Scripts/Maze/Combat/ITEM_CREATION_GUIDE.md

@@ -0,0 +1,107 @@
+# Item Creation Guide
+
+This guide explains how to create Weapons and Armor assets using the Unity Editor.
+
+## Quick Start
+
+### Creating a Weapon
+
+1. In the **Project** window, navigate to (or create) `Assets/Resources/Items/Weapons/`
+2. Right-click in the folder → **Create > Weapon**
+3. Name your weapon (e.g., `IronSword.asset`)
+4. Select the created asset and edit in the **Inspector**:
+   - **Item Name**: Display name (e.g., "Iron Sword")
+   - **Description**: Flavor text
+   - **Icon**: Drag a sprite for UI display
+   - **Rarity**: 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary
+   - **Attack Bonus**: Base attack increase (e.g., 5.0)
+   - **Magic Bonus**: Spell power boost (e.g., 0.0)
+   - **Defense Bonus**: Parry/block bonus (e.g., 1.0)
+   - **Speed Bonus**: Movement modifier (e.g., 0.0)
+
+### Creating Armor
+
+1. In the **Project** window, navigate to `Assets/Resources/Items/Armor/`
+2. Right-click → **Create > Armor**
+3. Name your armor (e.g., `LeatherArmor.asset`)
+4. Edit in the **Inspector**:
+   - **Item Name**: Display name
+   - **Description**: Flavor text
+   - **Icon**: Sprite for UI
+   - **Rarity**: Quality tier
+   - **Defense Bonus**: Protection increase (e.g., 5.0)
+   - **Health Bonus**: Extra durability (e.g., 0.0)
+   - **Speed Penalty**: Movement reduction for heavy armor (e.g., 2.0)
+   - **Magic Resistance**: Spell damage reduction (e.g., 0.0)
+
+## Loading Items at Runtime
+
+Once created in `Resources/Items/`, load them programmatically:
+
+```csharp
+// Load all weapons
+Weapon[] weapons = Resources.LoadAll<Weapon>("Items/Weapons/");
+
+// Load all armor
+Armor[] armors = Resources.LoadAll<Armor>("Items/Armor/");
+
+// Load a specific item
+Weapon sword = Resources.Load<Weapon>("Items/Weapons/IronSword");
+```
+
+## Example Presets
+
+### Common Weapon Presets
+
+**Iron Sword** (Common)
+- Attack Bonus: 5.0
+- Defense Bonus: 1.0
+
+**Steel Greatsword** (Uncommon)
+- Attack Bonus: 8.0
+- Speed Bonus: -0.5
+
+**Mage Staff** (Rare)
+- Attack Bonus: 2.0
+- Magic Bonus: 6.0
+
+### Common Armor Presets
+
+**Leather Armor** (Common)
+- Defense Bonus: 3.0
+- Speed Penalty: 0.0
+
+**Steel Plate** (Uncommon)
+- Defense Bonus: 8.0
+- Speed Penalty: 1.0
+- Health Bonus: 2.0
+
+**Mithril Robes** (Rare)
+- Defense Bonus: 4.0
+- Magic Resistance: 3.0
+
+## Behind the Scenes
+
+The `ItemCreationMenu` editor script (in `Combat/Editor/`) provides the **Create > Weapon** and **Create > Armor** menu items. When you use the menu:
+
+1. A save dialog appears
+2. The asset is created as a ScriptableObject
+3. It's automatically saved to the Resources folder
+4. The asset is pinged in the Project window for easy access
+
+**Resources folder structure:**
+```
+Assets/
+└── Resources/
+    └── Items/
+        ├── Weapons/    (auto-created)
+        │   ├── IronSword.asset
+        │   └── ...
+        └── Armor/      (auto-created)
+            ├── LeatherArmor.asset
+            └── ...
+```
+
+## Integration with Agents
+
+See `COPY_PASTE_EXAMPLES.cs` **Example 3** for how to equip agents with weapons and armor at spawn time.

+ 7 - 0
Assets/Scripts/Maze/Combat/ITEM_CREATION_GUIDE.md.meta

@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: aa9b499a86b2ba749a1f76eac8782738
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 43 - 0
Assets/Scripts/Maze/Combat/Items/Consumable.cs

@@ -0,0 +1,43 @@
+using UnityEngine;
+
+namespace MazeGen
+{
+    /// <summary>
+    /// A consumable item that can be used by the player to provide temporary bonuses or effects.
+    /// </summary>
+    public class Consumable : Item
+    {
+        [SerializeField] private float _defenseBonus = 0f;
+        [SerializeField] private float _healthBonus = 0f;       // Some armors grant extra durability
+        [SerializeField] private float _speedBonus = 0f;      // Heavy armor slows movement
+        [SerializeField] private float _magicResistance = 0f;   // Some armor resists magic damage
+
+        public float DefenseBonus => _defenseBonus;
+        public float HealthBonus => _healthBonus;
+        public float SpeedBonus => _speedBonus;
+        public float MagicResistance => _magicResistance;
+
+        public override bool OnEquip(CharacterSheet character)
+        {
+            if (!base.OnEquip(character)) return false;
+
+            if (_defenseBonus != 0) character.ApplyTemporaryBonus("consumable_defense", _defenseBonus, "defense");
+            if (_healthBonus != 0) character.ApplyTemporaryBonus("consumable_health", _healthBonus, "health");
+            if (_speedBonus != 0) character.ApplyTemporaryBonus("consumable_speed", _speedBonus, "speed");
+            if (_magicResistance != 0) character.ApplyTemporaryBonus("consumable_magic", _magicResistance, "magic");
+
+            return true;
+        }
+
+        public override void OnUnequip(CharacterSheet character)
+        {
+            character.RemoveTemporaryBonus("consumable_defense");
+            character.RemoveTemporaryBonus("consumable_health");
+            character.RemoveTemporaryBonus("consumable_speed");
+            character.RemoveTemporaryBonus("consumable_magic");
+            base.OnUnequip(character);
+        }
+
+        public override string GetLabel() => $"{ItemName} (Consumable)";
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Combat/Items/Consumable.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 680345347eaf82a4e979dcfffcdf848a

+ 106 - 0
Assets/Scripts/Maze/Combat/MONSTER_SPAWNING_SETUP.md

@@ -0,0 +1,106 @@
+# Monster Spawning Setup
+
+## What Was Fixed
+
+Monsters were being created as **data objects** but not being **visualized** in the scene. Here's what's now in place:
+
+### New Components Created
+
+1. **[MonsterView.cs](../Visualization/MonsterView.cs)** — Visual representation of a single monster
+   - Renders as a colored square (placeholder, swappable for real art)
+   - Color comes from the Monster template's color setting
+   - Destroys itself when defeated
+
+2. **[MonsterSpawner.cs](../Visualization/MonsterSpawner.cs)** — Orchestrates monster spawning
+   - Listens to `MazeGenerator.RoomGenerated` events
+   - When a room with an encounter is created, spawns MonsterView GameObjects
+   - Scatters monsters around the room in a circle pattern
+   - Organizes spawned monsters in the hierarchy under "SpawnedMonsters"
+
+3. **Updated [MazeSceneBuilder.cs](../Visualization/MazeSceneBuilder.cs)**
+   - Added `[RequireComponent(typeof(MonsterSpawner))]`
+   - MonsterSpawner is now automatically added to the scene
+
+## How It Works
+
+### Data Flow (Already Existing)
+```
+MazeGenerator.BuildRoom()
+  → if ShouldSpawnMonster() [70% chance by default]
+    → Creates MonsterEncounter with 1-3 monsters (random)
+    → Stores in room.Encounter
+```
+
+### Visualization Flow (NEW)
+```
+MonsterSpawner.OnRoomGenerated(room)
+  → if room.Encounter exists and has monsters
+    → For each monster, spawn MonsterView GameObject
+    → Position them in a circle around the room center
+    → Organize in hierarchy
+```
+
+## Configuration
+
+### In MazeGenerator (Inspector)
+- **Encounter Chance**: 0.7 (70% of rooms have monsters)
+- **Min Monsters Per Room**: 1
+- **Max Monsters Per Room**: 3
+- **Monster Pool**: Loaded from `Resources/Characters/Monsters/`
+
+### In Goblin Template (or Any Monster Asset)
+- **Color**: Determines visual appearance color
+- **Base Stats**: Health, attack, defense, etc.
+- **Starting Equipment**: Optional weapon/armor
+- **Possible Loot**: Items dropped when defeated
+
+## Creating New Monster Types
+
+1. In Unity Editor, right-click in Project folder → Create → Maze Gen → Monster
+2. Set the monster's properties (name, color, stats, loot)
+3. Save to `Assets/Resources/Characters/Monsters/YourMonster.asset`
+4. The system will automatically load it via `Resources.LoadAll<Monster>()`
+
+## Visual Hierarchy
+
+When playing, you'll see:
+```
+MazeSceneBuilder
+├── SpawnedMonsters (container)
+│   ├── Room0_Monsters
+│   │   ├── Goblin (MonsterView component)
+│   │   ├── Goblin
+│   │   └── ...
+│   ├── Room1_Monsters
+│   │   ├── Goblin
+│   │   └── ...
+│   └── ...
+├── [Room GameObjects from MazeSceneBuilder]
+└── ...
+```
+
+## Next Steps (Optional Enhancements)
+
+- [ ] Add animations when monsters spawn
+- [ ] Add combat interaction when agents encounter monsters
+- [ ] Add different monster types/prefabs
+- [ ] Implement monster AI movement
+- [ ] Add monster defeat feedback (sound, particles)
+- [ ] Replace placeholder colored squares with real art
+
+## Troubleshooting
+
+**"Monsters still don't appear"**
+- ✓ Check Console for errors during play
+- ✓ Verify Monster assets exist in `Assets/Resources/Characters/Monsters/`
+- ✓ Check that `encounterChance` > 0 in MazeGenerator
+- ✓ Ensure MazeSceneBuilder has MonsterSpawner component (should be automatic)
+
+**"Monster assets can't be found"**
+- Make sure assets are saved in `Assets/Resources/Characters/Monsters/`
+- Assets must inherit from `Monster` class (ScriptableObject)
+- Restart Unity Editor after adding new monster assets
+
+**"Compilation error about MonsterSpawner"**
+- This is just IDE lag — the MonsterSpawner script is in the correct namespace
+- Play the game anyway — it will compile fine in Unity

+ 7 - 0
Assets/Scripts/Maze/Combat/MONSTER_SPAWNING_SETUP.md.meta

@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 741c5a006e5bd3442929368af5877e5b
+TextScriptImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 7 - 1
Assets/Scripts/Maze/Combat/Monsters/Monster.cs

@@ -1,3 +1,4 @@
+using Unity.VisualScripting;
 using UnityEngine;
 
 namespace MazeGen
@@ -29,7 +30,12 @@ namespace MazeGen
         /// <summary>Create a fresh character sheet for a monster instance (with a copy of stats).</summary>
         public CharacterSheet CreateCharacterSheet()
         {
-            var sheet = new CharacterSheet(_baseStats.Clone());
+            // Pass null GameObject (Monster is a ScriptableObject, not a MonoBehaviour)
+            // CharacterSheet will use a default name instead
+            var sheet = new CharacterSheet(null, _baseStats.Clone());
+
+            // Override with the monster template's name
+            sheet.Name = _monsterName;
 
             if (_startingWeapon != null)
                 sheet.EquipWeapon(_startingWeapon);

+ 74 - 0
Assets/Scripts/Maze/Combat/PendingFight.cs

@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+
+namespace MazeGen
+{
+    public enum FightState
+    {
+        /// <summary>Declared, but not started yet - the join window is counting down.</summary>
+        Mustering,
+
+        /// <summary>Outcome is decided; if it's being watched, playback is still running.</summary>
+        Resolving,
+
+        Resolved,
+
+        /// <summary>Never happened (every participant left/died, or the monsters were already dead).</summary>
+        Cancelled
+    }
+
+    /// <summary>Whether a fight's outcome is just logged, or played out on the arena grid.</summary>
+    public enum CombatViewMode
+    {
+        Simulate,
+        Watch
+    }
+
+    /// <summary>
+    /// A fight that has been declared in a room but hasn't started yet. The join window is the
+    /// whole point of this type existing: an agent that picks a fight doesn't fight immediately,
+    /// it commits and then waits, and during that wait other agents can walk in and pile on
+    /// (improving the odds) or try to sneak past.
+    /// </summary>
+    public class PendingFight
+    {
+        public readonly int Id;
+        public readonly Room Room;
+        public readonly MonsterEncounter Encounter;
+
+        /// <summary>Agents committed to this fight, in join order. The first is whoever started it.</summary>
+        public readonly List<MazeAgent> Agents = new List<MazeAgent>();
+
+        /// <summary>Seconds of join window left. Ticks on SimulationClock time, so pausing freezes it.</summary>
+        public float JoinTimeRemaining;
+
+        public FightState State = FightState.Mustering;
+
+        /// <summary>Why this fight started, for the log/HUD.</summary>
+        public string Trigger;
+
+        /// <summary>Null until resolution.</summary>
+        public BattleResult Result;
+
+        /// <summary>How this fight was (or will be) resolved.</summary>
+        public CombatViewMode ResolvedAs;
+
+        /// <summary>
+        /// The observer's choice of Watch/Log for when the join window naturally expires. Setting
+        /// this (via CombatDirector.PreferWatch/PreferSimulate) is only a preference, not a trigger -
+        /// the join window always has to run out on its own first; see CombatDirector.Update.
+        /// Null means "use the director's default timeout resolution".
+        /// </summary>
+        public CombatViewMode? PreferredResolution;
+
+        public PendingFight(int id, Room room, MonsterEncounter encounter)
+        {
+            Id = id;
+            Room = room;
+            Encounter = encounter;
+        }
+
+        public bool Contains(MazeAgent agent) => agent != null && Agents.Contains(agent);
+
+        public string Describe() => $"Fight #{Id} in room {Room.Id}: {Agents.Count} agent(s) vs {Encounter.AliveMonsterCount} monster(s)";
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Combat/PendingFight.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2eb22c5bce1d20b4d834f7cbdfc13d5c

+ 27 - 0
Assets/Scripts/Maze/Generation/MazeGenerator.cs

@@ -18,6 +18,14 @@ namespace MazeGen
 
         private MazeGraph _graph;
 
+        // This should be handled by a monster handler
+        private Monster[] monsterPool;
+        [SerializeField] private float encounterChance = 0.7f;
+        [SerializeField] private int minMonstersPerRoom = 1;
+        [SerializeField] private int maxMonstersPerRoom = 3;
+        // And or settings for monster pool, etc. should be handled by a monster handler
+
+
         public MazeGraph Graph => _graph;
         public MazeGenerationSettings Settings => settings;
 
@@ -35,6 +43,9 @@ namespace MazeGen
             if (settings == null)
                 settings = ScriptableObject.CreateInstance<MazeGenerationSettings>();
 
+            if (monsterPool == null || monsterPool.Length == 0)
+                monsterPool = Resources.LoadAll<Monster>("Characters/Monsters/");
+
             _graph = new MazeGraph(settings.roomSpacing);
         }
 
@@ -144,12 +155,28 @@ namespace MazeGen
 
             ConnectExits(connectingExit, entryExit);
 
+            // Create encounter BEFORE firing events, so subscribers see it immediately
+            if (ShouldSpawnMonster())
+            {
+                var encounter = new MonsterEncounter();
+                encounter.SpawnMonsters(monsterPool, minMonstersPerRoom, maxMonstersPerRoom);
+                room.Encounter = encounter;
+            }
+
             _graph.Register(room);
             RoomGenerated?.Invoke(room);
             RoomsConnected?.Invoke(connectingRoom, connectingExit, room);
+
             return room;
         }
 
+        private bool ShouldSpawnMonster()
+        {
+            if (monsterPool == null || monsterPool.Length == 0)
+                return false;
+            return UnityEngine.Random.value < encounterChance;
+        }
+
         /// <summary>
         /// Called only once the straight-line spawn spot is known to overlap something and no
         /// existing door offered a connection. First tries extending further out along the

+ 10 - 0
Assets/Scripts/Maze/Input/MazeInput.cs

@@ -53,6 +53,16 @@ namespace MazeGen
 #endif
         }
 
+        /// <summary>True on the frame the space bar goes down - used to toggle the simulation pause.</summary>
+        public static bool WasPauseTogglePressed()
+        {
+#if ENABLE_INPUT_SYSTEM
+            return Keyboard.current != null && Keyboard.current.spaceKey.wasPressedThisFrame;
+#else
+            return Input.GetKeyDown(KeyCode.Space);
+#endif
+        }
+
         /// <summary>Normalized WASD movement axis: (right/left, up/down).</summary>
         public static Vector2 ReadWasdAxis()
         {

+ 8 - 0
Assets/Scripts/Maze/Simulation.meta

@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: c73b137a9ea0a514cb6303473c4750e6
+folderAsset: yes
+DefaultImporter:
+  externalObjects: {}
+  userData: 
+  assetBundleName: 
+  assetBundleVariant: 

+ 68 - 0
Assets/Scripts/Maze/Simulation/SimulationClock.cs

@@ -0,0 +1,68 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MazeGen
+{
+    /// <summary>
+    /// The single source of truth for "is the maze simulation running right now".
+    ///
+    /// Everything that advances maze state (agent decision timers, fight muster countdowns,
+    /// the level's elapsed-time clock) must tick on <see cref="DeltaTime"/> rather than
+    /// <see cref="Time.deltaTime"/>, so that one pause freezes the whole simulation coherently.
+    ///
+    /// Deliberately does NOT touch <see cref="Time.timeScale"/>: real Unity time has to keep
+    /// flowing while the maze is frozen, because that's what drives the watched-fight playback
+    /// animation and its coroutines.
+    ///
+    /// Pausing is ref-counted by reason string (<see cref="PushHold"/>/<see cref="PopHold"/>) so
+    /// several independent things - three fights breaking out at once, plus an arena playback -
+    /// can each hold the simulation without stomping on each other's resume. The user's own
+    /// pause button is separate (<see cref="UserPaused"/>) and always wins.
+    /// </summary>
+    public static class SimulationClock
+    {
+        private static readonly HashSet<string> _holds = new HashSet<string>();
+        private static bool _userPaused;
+        private static float _timeScale = 1f;
+
+        /// <summary>The observer's manual pause. Independent of, and additive with, any holds.</summary>
+        public static bool UserPaused
+        {
+            get => _userPaused;
+            set => _userPaused = value;
+        }
+
+        /// <summary>Fast-forward / slow-motion multiplier for maze simulation time.</summary>
+        public static float TimeScale
+        {
+            get => _timeScale;
+            set => _timeScale = Mathf.Clamp(value, 0.1f, 8f);
+        }
+
+        public static bool IsPaused => _userPaused || _holds.Count > 0;
+
+        /// <summary>Human-readable list of what's currently holding the simulation (null if nothing is).</summary>
+        public static string HoldSummary => _holds.Count == 0 ? null : string.Join(", ", _holds);
+
+        /// <summary>Scaled simulation delta for this frame - zero while paused.</summary>
+        public static float DeltaTime => IsPaused ? 0f : Time.deltaTime * _timeScale;
+
+        public static void PushHold(string reason) => _holds.Add(reason ?? "hold");
+
+        public static void PopHold(string reason) => _holds.Remove(reason ?? "hold");
+
+        public static bool HasHold(string reason) => _holds.Contains(reason ?? "hold");
+
+        /// <summary>
+        /// Clears all state. Called from MazeSceneBuilder.Awake because this is static state and
+        /// the editor can be configured to skip domain reload between Play sessions, which would
+        /// otherwise leave a stale pause hold from the previous run holding the new one frozen.
+        /// </summary>
+        public static void Reset()
+        {
+            _holds.Clear();
+            _userPaused = false;
+            _timeScale = 1f;
+        }
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Simulation/SimulationClock.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 85fc9484752328f4fb8b96c9be438dae

+ 525 - 0
Assets/Scripts/Maze/Visualization/BattleArenaView.cs

@@ -0,0 +1,525 @@
+using System.Collections;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+
+namespace MazeGen
+{
+    /// <summary>
+    /// Plays a fight out on a grid in its own additively-loaded scene, then throws that scene
+    /// away and hands the camera back to the maze.
+    ///
+    /// Why this can't corrupt the maze: the fight's outcome is already fully decided before
+    /// playback starts (see <see cref="CombatResolver"/>), so this class is pure animation over a
+    /// finished <see cref="BattleResult"/> - it never touches a character sheet, a room, or an
+    /// agent. Health bars run off a private display copy of each combatant's health. And because
+    /// everything it creates lives in a separate scene which is unloaded on exit, there's nothing
+    /// left behind to restore. The maze's own GameObjects are never disabled or re-created (which
+    /// would kill the spawn coroutines), only hidden by suspending the maze camera; when the fight
+    /// ends every agent is exactly where it was, and only the fight's result has changed.
+    ///
+    /// Runs on real Unity time, because the maze simulation is frozen via SimulationClock (not
+    /// Time.timeScale) precisely so that this playback can still animate.
+    /// </summary>
+    [DisallowMultipleComponent]
+    public class BattleArenaView : MonoBehaviour
+    {
+        [Header("Arena")]
+        [Tooltip("Grid dimensions (columns x rows) of the battle floor.")]
+        [SerializeField] private Vector2Int gridSize = new Vector2Int(11, 7);
+        [SerializeField] private float cellSize = 1f;
+        [Tooltip("Where in world space the arena scene is built. Far from the maze so the arena camera can't see maze geometry.")]
+        [SerializeField] private Vector2 arenaWorldOrigin = new Vector2(0f, 10000f);
+
+        [Header("Playback")]
+        [Tooltip("Base tempo: seconds per combat event at 1x speed.")]
+        [SerializeField, Min(0.02f)] private float secondsPerEvent = 0.4f;
+        [SerializeField, Range(0.25f, 8f)] private float playbackSpeed = 1f;
+        [Tooltip("Seconds the final result stays on screen before auto-returning to the maze (0 = wait for the button).")]
+        [SerializeField, Min(0f)] private float autoReturnSeconds = 4f;
+
+        /// <summary>Per-combatant playback state. DisplayHealth is a copy - never the real sheet.</summary>
+        private class Slot
+        {
+            public Combatant Combatant;
+            public Transform Transform;
+            public SpriteRenderer Sprite;
+            public Vector3 Home;
+            public float DisplayHealth;
+            public bool Felled;
+        }
+
+        private struct Floater
+        {
+            public Vector3 WorldPosition;
+            public string Text;
+            public Color Color;
+            public float BornAt;
+        }
+
+        private readonly Dictionary<int, Slot> _slots = new Dictionary<int, Slot>();
+        private readonly List<Floater> _floaters = new List<Floater>();
+        private readonly List<string> _feed = new List<string>();
+        private readonly List<Camera> _suspendedCameras = new List<Camera>();
+
+        private Scene _scene;
+        private bool _sceneLoaded;
+        private GameObject _root;
+        private Camera _camera;
+        private PendingFight _fight;
+        private BattleResult _result;
+        private int _round;
+        private bool _playbackFinished;
+        private bool _skipRequested;
+        private bool _returnRequested;
+        private Vector2 _feedScroll;
+
+        public bool IsBuilt => _sceneLoaded;
+
+        /// <summary>Seconds per animation "beat" at the current playback speed.</summary>
+        private float Beat => Mathf.Max(0.02f, secondsPerEvent) / Mathf.Max(0.25f, playbackSpeed);
+
+        /// <summary>
+        /// Builds the arena, replays the fight, waits for the observer, and tears everything back
+        /// down. Yield on this from a coroutine; when it completes the maze is on screen again.
+        /// </summary>
+        public IEnumerator Play(PendingFight fight, IReadOnlyList<Combatant> combatants)
+        {
+            _fight = fight;
+            _result = fight.Result;
+            _skipRequested = false;
+            _returnRequested = false;
+            _playbackFinished = false;
+            _round = 0;
+            _feed.Clear();
+            _floaters.Clear();
+
+            Build(combatants);
+
+            yield return Replay();
+
+            ApplyFinalState();
+            _playbackFinished = true;
+
+            yield return WaitForObserver();
+
+            Teardown();
+        }
+
+        // ---------------------------------------------------------------- build / teardown
+
+        private void Build(IReadOnlyList<Combatant> combatants)
+        {
+            // A per-fight scene name: CreateScene refuses to make a second scene with a name
+            // that's already loaded, and a previous arena may still be unloading.
+            _scene = SceneManager.CreateScene($"CombatArena_{_fight.Id}_{Time.frameCount}");
+            _sceneLoaded = true;
+
+            _root = new GameObject("ArenaRoot");
+            SceneManager.MoveGameObjectToScene(_root, _scene);
+            _root.transform.position = arenaWorldOrigin;
+
+            BuildFloor();
+            BuildCamera();
+            PlaceCombatants(combatants);
+        }
+
+        private void BuildFloor()
+        {
+            var floor = new GameObject("Floor");
+            floor.transform.SetParent(_root.transform, false);
+
+            for (int x = 0; x < gridSize.x; x++)
+            {
+                for (int y = 0; y < gridSize.y; y++)
+                {
+                    var tile = new GameObject($"Cell_{x}_{y}");
+                    tile.transform.SetParent(floor.transform, false);
+                    tile.transform.position = CellToWorld(x, y);
+                    tile.transform.localScale = Vector3.one * (cellSize * 0.96f);
+
+                    var sr = tile.AddComponent<SpriteRenderer>();
+                    sr.sprite = RoomView.SharedSquareSprite;
+                    sr.color = (x + y) % 2 == 0 ? new Color(0.16f, 0.17f, 0.2f) : new Color(0.21f, 0.22f, 0.26f);
+                    sr.sortingOrder = 0;
+                }
+            }
+        }
+
+        private void BuildCamera()
+        {
+            var cameraGO = new GameObject("ArenaCamera");
+            cameraGO.transform.SetParent(_root.transform, false);
+            cameraGO.transform.position = new Vector3(arenaWorldOrigin.x, arenaWorldOrigin.y, -10f);
+
+            _camera = cameraGO.AddComponent<Camera>();
+            _camera.orthographic = true;
+            _camera.backgroundColor = new Color(0.05f, 0.05f, 0.07f);
+            _camera.clearFlags = CameraClearFlags.SolidColor;
+            _camera.depth = 100f; // draw over anything else that somehow stays enabled
+
+            float aspect = Mathf.Max(_camera.aspect, 0.01f);
+            float halfHeight = gridSize.y * cellSize * 0.5f;
+            float halfWidth = gridSize.x * cellSize * 0.5f;
+            _camera.orthographicSize = Mathf.Max(halfHeight, halfWidth / aspect) * 1.35f;
+
+            // Hide the maze by suspending its cameras rather than deactivating maze objects -
+            // SetActive(false) on the maze root would permanently kill its spawn coroutines.
+            _suspendedCameras.Clear();
+            foreach (var cam in Camera.allCameras)
+            {
+                if (cam == _camera) continue;
+                _suspendedCameras.Add(cam);
+                cam.enabled = false;
+            }
+        }
+
+        /// <summary>Agents line up on the left of the grid, monsters on the right, facing off.</summary>
+        private void PlaceCombatants(IReadOnlyList<Combatant> combatants)
+        {
+            _slots.Clear();
+
+            var agents = new List<Combatant>();
+            var monsters = new List<Combatant>();
+            foreach (var c in combatants)
+                (c.Team == Combatant.TeamAgents ? agents : monsters).Add(c);
+
+            LayoutTeam(agents, true);
+            LayoutTeam(monsters, false);
+        }
+
+        private void LayoutTeam(List<Combatant> team, bool leftSide)
+        {
+            int rows = Mathf.Max(1, gridSize.y);
+
+            for (int i = 0; i < team.Count; i++)
+            {
+                int column = i / rows;
+                int indexInColumn = i % rows;
+                int countInColumn = Mathf.Min(rows, team.Count - column * rows);
+
+                int x = leftSide ? Mathf.Min(column, gridSize.x - 1) : Mathf.Max(0, gridSize.x - 1 - column);
+                int y = (rows - countInColumn) / 2 + indexInColumn;
+
+                _slots[team[i].Id] = CreateSlot(team[i], CellToWorld(x, y));
+            }
+        }
+
+        private Slot CreateSlot(Combatant combatant, Vector3 position)
+        {
+            var go = new GameObject($"{(combatant.IsAgent ? "Agent" : "Monster")}_{combatant.Name}");
+            go.transform.SetParent(_root.transform, false);
+            go.transform.position = position;
+            go.transform.localScale = Vector3.one * (cellSize * (combatant.IsAgent ? 0.58f : 0.5f));
+
+            var sr = go.AddComponent<SpriteRenderer>();
+            sr.sprite = RoomView.SharedSquareSprite;
+            sr.color = combatant.Color;
+            sr.sortingOrder = 2;
+
+            return new Slot
+            {
+                Combatant = combatant,
+                Transform = go.transform,
+                Sprite = sr,
+                Home = position,
+                DisplayHealth = combatant.StartHealth
+            };
+        }
+
+        private void Teardown()
+        {
+            foreach (var cam in _suspendedCameras)
+                if (cam != null) cam.enabled = true;
+            _suspendedCameras.Clear();
+
+            _slots.Clear();
+            _floaters.Clear();
+            _camera = null;
+            _root = null;
+
+            if (_sceneLoaded && _scene.IsValid() && _scene.isLoaded)
+                SceneManager.UnloadSceneAsync(_scene);
+
+            _sceneLoaded = false;
+            _fight = null;
+            _result = null;
+        }
+
+        // ---------------------------------------------------------------- playback
+
+        private IEnumerator Replay()
+        {
+            foreach (var e in _result.Events)
+            {
+                if (_skipRequested || _returnRequested) break;
+
+                Feed(e.Text);
+
+                switch (e.Kind)
+                {
+                    case CombatEventKind.Hit:
+                    case CombatEventKind.Miss:
+                        yield return Strike(e);
+                        break;
+                    case CombatEventKind.Death:
+                        yield return Fell(e.TargetId);
+                        break;
+                    case CombatEventKind.RoundStart:
+                        _round = e.Round;
+                        yield return Hold(0.6f);
+                        break;
+                    default:
+                        yield return Hold(1.2f);
+                        break;
+                }
+            }
+        }
+
+        private IEnumerator Strike(CombatEvent e)
+        {
+            if (!_slots.TryGetValue(e.ActorId, out Slot actor) || !_slots.TryGetValue(e.TargetId, out Slot target))
+            {
+                yield return Hold(0.5f);
+                yield break;
+            }
+
+            Vector3 lunge = Vector3.Lerp(actor.Home, target.Home, 0.5f);
+            yield return Glide(actor, actor.Home, lunge, 0.45f);
+
+            if (e.Kind == CombatEventKind.Hit)
+            {
+                target.DisplayHealth = Mathf.Max(0f, target.DisplayHealth - e.Amount);
+                AddFloater(target, $"-{e.Amount:0.#}", new Color(1f, 0.45f, 0.4f));
+                yield return Flash(target, new Color(1f, 0.35f, 0.35f));
+            }
+            else
+            {
+                AddFloater(target, "miss", new Color(0.85f, 0.85f, 0.85f));
+                yield return Flash(target, Color.white);
+            }
+
+            yield return Glide(actor, lunge, actor.Home, 0.3f);
+        }
+
+        private IEnumerator Fell(int combatantId)
+        {
+            if (!_slots.TryGetValue(combatantId, out Slot slot)) yield break;
+
+            slot.Felled = true;
+            slot.DisplayHealth = 0f;
+
+            Vector3 startScale = slot.Transform.localScale;
+            Color startColor = slot.Sprite.color;
+            float duration = Beat * 0.9f;
+
+            for (float t = 0f; t < duration; t += Time.deltaTime)
+            {
+                if (_returnRequested) break;
+                float k = Mathf.Clamp01(t / duration);
+                slot.Transform.localScale = Vector3.Lerp(startScale, startScale * 0.35f, k);
+                slot.Sprite.color = new Color(startColor.r * 0.4f, startColor.g * 0.4f, startColor.b * 0.4f, 1f - 0.65f * k);
+                slot.Transform.rotation = Quaternion.Euler(0f, 0f, 90f * k);
+                yield return null;
+            }
+
+            slot.Transform.localScale = startScale * 0.35f;
+            slot.Sprite.color = new Color(startColor.r * 0.4f, startColor.g * 0.4f, startColor.b * 0.4f, 0.35f);
+            slot.Transform.rotation = Quaternion.Euler(0f, 0f, 90f);
+        }
+
+        private IEnumerator Glide(Slot slot, Vector3 from, Vector3 to, float beats)
+        {
+            float duration = Beat * beats;
+            for (float t = 0f; t < duration; t += Time.deltaTime)
+            {
+                if (_skipRequested || _returnRequested) break;
+                slot.Transform.position = Vector3.Lerp(from, to, Mathf.Clamp01(t / duration));
+                yield return null;
+            }
+            slot.Transform.position = to;
+        }
+
+        private IEnumerator Flash(Slot slot, Color flashColor)
+        {
+            Color original = slot.Sprite.color;
+            slot.Sprite.color = flashColor;
+            yield return Hold(0.35f);
+            if (!slot.Felled) slot.Sprite.color = original;
+        }
+
+        private IEnumerator Hold(float beats)
+        {
+            float duration = Beat * beats;
+            for (float t = 0f; t < duration; t += Time.deltaTime)
+            {
+                if (_skipRequested || _returnRequested) yield break;
+                yield return null;
+            }
+        }
+
+        /// <summary>
+        /// Snaps every combatant to the true final state of the decided battle, so a skipped or
+        /// interrupted playback still ends on an accurate picture.
+        /// </summary>
+        private void ApplyFinalState()
+        {
+            foreach (var slot in _slots.Values)
+            {
+                slot.DisplayHealth = slot.Combatant.Sheet.Stats.CurrentHealth;
+                slot.Transform.position = slot.Home;
+
+                if (!slot.Combatant.IsAlive && !slot.Felled)
+                {
+                    slot.Felled = true;
+                    Color c = slot.Sprite.color;
+                    slot.Sprite.color = new Color(c.r * 0.4f, c.g * 0.4f, c.b * 0.4f, 0.35f);
+                    slot.Transform.localScale *= 0.35f;
+                    slot.Transform.rotation = Quaternion.Euler(0f, 0f, 90f);
+                }
+                else if (!slot.Felled)
+                {
+                    slot.Sprite.color = slot.Combatant.Color;
+                }
+            }
+
+            if (_skipRequested)
+                Feed("(skipped to the end)");
+            Feed(_result.Summary);
+        }
+
+        private IEnumerator WaitForObserver()
+        {
+            float waited = 0f;
+            while (!_returnRequested)
+            {
+                if (autoReturnSeconds > 0f && waited >= autoReturnSeconds) break;
+                waited += Time.deltaTime;
+                yield return null;
+            }
+        }
+
+        // ---------------------------------------------------------------- helpers & HUD
+
+        private Vector3 CellToWorld(int x, int y)
+        {
+            float ox = (x - (gridSize.x - 1) * 0.5f) * cellSize;
+            float oy = (y - (gridSize.y - 1) * 0.5f) * cellSize;
+            return new Vector3(arenaWorldOrigin.x + ox, arenaWorldOrigin.y + oy, 0f);
+        }
+
+        private void AddFloater(Slot slot, string text, Color color)
+        {
+            _floaters.Add(new Floater
+            {
+                WorldPosition = slot.Home + Vector3.up * (cellSize * 0.5f),
+                Text = text,
+                Color = color,
+                BornAt = Time.time
+            });
+        }
+
+        private void Feed(string line)
+        {
+            if (string.IsNullOrEmpty(line)) return;
+            _feed.Add(line);
+            if (_feed.Count > 200) _feed.RemoveAt(0);
+            _feedScroll.y = float.MaxValue; // keep pinned to the newest line
+        }
+
+        private void OnGUI()
+        {
+            if (!_sceneLoaded || _camera == null || _fight == null) return;
+
+            DrawNameplates();
+            DrawFloaters();
+            DrawPanel();
+        }
+
+        private void DrawNameplates()
+        {
+            var style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11 };
+
+            foreach (var slot in _slots.Values)
+            {
+                Vector3 screen = _camera.WorldToScreenPoint(slot.Home);
+                if (screen.z < 0f) continue;
+
+                float max = slot.Combatant.MaxHealth;
+                float fraction = Mathf.Clamp01(max > 0f ? slot.DisplayHealth / max : 0f);
+                var rect = new Rect(screen.x - 55f, Screen.height - screen.y - 44f, 110f, 30f);
+
+                GUI.color = slot.Felled ? new Color(0.6f, 0.6f, 0.6f) : Color.white;
+                GUI.Label(rect, $"{slot.Combatant.Name}\n{slot.DisplayHealth:0}/{max:0}", style);
+                GUI.color = Color.white;
+
+                // Health bar under the nameplate.
+                var barBack = new Rect(screen.x - 26f, Screen.height - screen.y - 16f, 52f, 5f);
+                GUI.color = new Color(0f, 0f, 0f, 0.6f);
+                GUI.DrawTexture(barBack, Texture2D.whiteTexture);
+                GUI.color = slot.Combatant.IsAgent ? new Color(0.4f, 0.9f, 0.5f) : new Color(0.9f, 0.4f, 0.4f);
+                GUI.DrawTexture(new Rect(barBack.x, barBack.y, barBack.width * fraction, barBack.height), Texture2D.whiteTexture);
+                GUI.color = Color.white;
+            }
+        }
+
+        private void DrawFloaters()
+        {
+            const float lifetime = 1.1f;
+            var style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold };
+
+            for (int i = _floaters.Count - 1; i >= 0; i--)
+            {
+                Floater floater = _floaters[i];
+                float age = Time.time - floater.BornAt;
+                if (age > lifetime)
+                {
+                    _floaters.RemoveAt(i);
+                    continue;
+                }
+
+                Vector3 screen = _camera.WorldToScreenPoint(floater.WorldPosition);
+                if (screen.z < 0f) continue;
+
+                GUI.color = new Color(floater.Color.r, floater.Color.g, floater.Color.b, 1f - age / lifetime);
+                GUI.Label(new Rect(screen.x - 40f, Screen.height - screen.y - 30f - age * 25f, 80f, 20f), floater.Text, style);
+                GUI.color = Color.white;
+            }
+        }
+
+        private void DrawPanel()
+        {
+            const float width = 380f;
+            GUILayout.BeginArea(new Rect(Screen.width - width - 10f, 10f, width, Screen.height - 20f), GUI.skin.box);
+
+            GUILayout.Label($"FIGHT #{_fight.Id} - room {_fight.Room.Id}");
+            GUILayout.Label(_playbackFinished
+                ? _result.Summary
+                : $"Round {_round} / max - {_fight.Agents.Count} agent(s) vs {_result.Combatants.Count - _fight.Agents.Count} monster(s)");
+
+            GUILayout.BeginHorizontal();
+            GUILayout.Label("Speed", GUILayout.Width(45f));
+            playbackSpeed = GUILayout.HorizontalSlider(playbackSpeed, 0.25f, 8f);
+            GUILayout.Label($"{playbackSpeed:0.0}x", GUILayout.Width(40f));
+            GUILayout.EndHorizontal();
+
+            if (!_playbackFinished)
+            {
+                if (GUILayout.Button("Skip to end")) _skipRequested = true;
+            }
+            else if (GUILayout.Button("Return to the maze"))
+            {
+                _returnRequested = true;
+            }
+
+            GUILayout.Space(6f);
+            GUILayout.Label("Blow by blow:");
+            _feedScroll = GUILayout.BeginScrollView(_feedScroll);
+            foreach (var line in _feed)
+                GUILayout.Label(line);
+            GUILayout.EndScrollView();
+
+            GUILayout.EndArea();
+        }
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Visualization/BattleArenaView.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a100ae72bde13ac48bd767bf2cfadda3

+ 226 - 12
Assets/Scripts/Maze/Visualization/MazeSceneBuilder.cs

@@ -9,11 +9,21 @@ namespace MazeGen
     /// Scene entry point: drop this on one empty GameObject in the scene and press Play.
     /// Generates the configured start rooms, then spawns and drives a configurable number
     /// of autonomous agents that explore the maze on their own (see AutonomousAgentBrain).
-    /// The level ends when enough agents reach an exit room or a time limit is hit. A simple
-    /// IMGUI panel lists all agents (click to have the camera follow one) and offers a
-    /// "zoom to fit maze" button.
+    /// The level ends when enough agents reach an exit room or a time limit is hit.
+    ///
+    /// Also owns the observation HUD:
+    /// - simulation controls (pause/speed - the sim runs on SimulationClock, not Time.timeScale)
+    /// - the agent list (click to have the camera follow one)
+    /// - "Agent thoughts and info" for the followed agent, fed by its AgentThoughtLog
+    /// - live fights with their join-window countdown and watch/simulate controls
+    /// - the global combat log
+    /// The HUD hides itself entirely while a fight is being watched, since BattleArenaView
+    /// draws its own.
     /// </summary>
     [RequireComponent(typeof(MazeGenerator))]
+    [RequireComponent(typeof(MonsterSpawner))]
+    [RequireComponent(typeof(CombatDirector))]
+    [RequireComponent(typeof(BattleArenaView))]
     public class MazeSceneBuilder : MonoBehaviour
     {
         [Header("Agents")]
@@ -26,6 +36,8 @@ namespace MazeGen
         [SerializeField] private float decisionInterval = 1.5f;
         [Tooltip("Chance an agent explores a brand-new exit even when a 'follow' (known but personally-unvisited) option is also available.")]
         [SerializeField, Range(0f, 1f)] private float exploreChance = 0.85f;
+        [Tooltip("Perceived challenge rating an agent will take on alone. Above this it avoids the room, or tries to sneak past instead of fighting.")]
+        [SerializeField] private float retreatThreshold = 1.5f;
 
         [Header("Win conditions (at least one must be > 0)")]
         [SerializeField] private int targetFinishedAgentCount = 1;
@@ -39,6 +51,8 @@ namespace MazeGen
         }
 
         private MazeGenerator _generator;
+        private MonsterSpawner _monsterSpawner;
+        private CombatDirector _director;
         private MazeCameraController _cameraController;
 
         private readonly Dictionary<int, ExitMarker> _exitMarkers = new Dictionary<int, ExitMarker>();
@@ -48,13 +62,28 @@ namespace MazeGen
         private bool _levelEnded;
         private float _elapsedTime;
         private Vector2 _hudScroll;
+        private Vector2 _thoughtScroll;
+        private Vector2 _combatLogScroll;
+        private Vector2 _fightScroll;
 
         private void Awake()
         {
+            // Static state, and the editor can skip domain reload between Play sessions - clear
+            // it so a stale pause hold from the last run can't freeze this one on startup.
+            SimulationClock.Reset();
+
             _generator = GetComponent<MazeGenerator>();
             _generator.RoomGenerated += OnRoomGenerated;
             _generator.ExitAdded += OnExitAdded;
 
+            _monsterSpawner = GetComponent<MonsterSpawner>();
+
+            // RequireComponent only auto-adds when the script is attached in the editor, so a
+            // scene saved before these existed would come up without them - add them if missing
+            // rather than null-referencing through the whole HUD.
+            _director = GetComponent<CombatDirector>() ?? gameObject.AddComponent<CombatDirector>();
+            _director.FightResolved += OnFightResolved;
+
             var cam = Camera.main;
             if (cam != null)
             {
@@ -169,12 +198,16 @@ namespace MazeGen
 
             var brain = agentGO.AddComponent<AutonomousAgentBrain>();
             float initialDelay = Random.Range(initialDecisionDelayRange.x, initialDecisionDelayRange.y);
-            brain.Initialize(controller, _generator, initialDelay, decisionInterval, exploreChance, 1.0f, () => _levelEnded);
+            brain.Initialize(controller, _generator, _director, initialDelay, decisionInterval, exploreChance, retreatThreshold, () => _levelEnded);
+
+            // The director needs the roster to know who else is standing in a room when a fight
+            // breaks out there, and which color to draw each agent with in the arena.
+            _director.RegisterAgent(controller, color);
 
             controller.Agent.RoomEntered += room => path.AppendPoint(room.Position);
             controller.Agent.RoomEntered += _ => RefreshMarkerVisuals();
             controller.Agent.RoomEntered += _ => CheckFinishCondition();
-            controller.Agent.ReachedExitRoom += room => Debug.Log($"[MazeGen] {agentGO.name} reached the exit room (id {room.Id})!");
+            controller.Agent.ReachedExitRoom += room => Debug.Log($"[MazeGen] {controller.Agent.DisplayName} reached the exit room (id {room.Id})!");
 
             _agents.Add(new AgentEntry { Controller = controller, Path = path, Color = color });
         }
@@ -212,11 +245,23 @@ namespace MazeGen
                 marker.UpdateVisual(currentRoom, agent);
         }
 
+        /// <summary>A fight is over: drop the views of anything that died in it and recheck the level.</summary>
+        private void OnFightResolved(PendingFight fight)
+        {
+            _monsterSpawner?.RefreshRoom(fight.Room);
+            RefreshMarkerVisuals();
+            CheckFinishCondition();
+        }
+
         private void Update()
         {
+            if (MazeInput.WasPauseTogglePressed())
+                SimulationClock.UserPaused = !SimulationClock.UserPaused;
+
             if (_levelEnded) return;
 
-            _elapsedTime += Time.deltaTime;
+            // The level clock is simulation time, so pausing to watch a fight doesn't burn it.
+            _elapsedTime += SimulationClock.DeltaTime;
             if (timeLimitSeconds > 0f && _elapsedTime >= timeLimitSeconds)
                 EndLevel($"Time limit of {timeLimitSeconds:0.0}s reached.");
         }
@@ -227,7 +272,13 @@ namespace MazeGen
 
             int finished = _agents.Count(a => a.Controller.Agent.IsFinished);
             if (targetFinishedAgentCount > 0 && finished >= targetFinishedAgentCount)
+            {
                 EndLevel($"{finished} agent(s) reached the exit.");
+                return;
+            }
+
+            if (_agents.Count > 0 && _agents.Count >= agentCount && _agents.All(a => a.Controller.Agent.IsDead || a.Controller.Agent.IsFinished))
+                EndLevel($"Every agent is dead or finished ({finished} reached the exit).");
         }
 
         private void EndLevel(string reason)
@@ -273,20 +324,36 @@ namespace MazeGen
             return bounds;
         }
 
+        // ---------------------------------------------------------------- HUD
+
         private void OnGUI()
         {
-            GUILayout.BeginArea(new Rect(10, 10, 260, Screen.height - 20), GUI.skin.box);
+            // The arena draws its own HUD and owns the screen while a fight is being watched.
+            if (_director != null && _director.IsWatching) return;
+
+            DrawStatusPanel();
+            DrawFightPanel();
+            DrawFollowedAgentPanel();
+        }
+
+        private void DrawStatusPanel()
+        {
+            GUILayout.BeginArea(new Rect(10, 10, 280, Screen.height - 20), GUI.skin.box);
 
             string timeLabel = timeLimitSeconds > 0f ? $"{_elapsedTime:0.0}s / {timeLimitSeconds:0.0}s" : $"{_elapsedTime:0.0}s";
             GUILayout.Label($"Elapsed: {timeLabel}");
 
             int finishedCount = _agents.Count(a => a.Controller.Agent.IsFinished);
             string finishLabel = targetFinishedAgentCount > 0 ? $"{finishedCount} / {targetFinishedAgentCount}" : $"{finishedCount}";
-            GUILayout.Label($"Finished: {finishLabel}");
+            GUILayout.Label($"Finished: {finishLabel}    Dead: {_agents.Count(a => a.Controller.Agent.IsDead)}");
 
             if (_levelEnded)
                 GUILayout.Label("LEVEL COMPLETE", GUI.skin.box);
 
+            GUILayout.Space(4);
+            DrawSimulationControls();
+
+            GUILayout.Space(4);
             if (GUILayout.Button("Zoom to fit maze"))
                 _cameraController?.FrameBounds(ComputeMazeBounds());
 
@@ -298,12 +365,11 @@ namespace MazeGen
             _hudScroll = GUILayout.BeginScrollView(_hudScroll);
             foreach (var entry in _agents)
             {
-                var agent = entry.Controller.Agent;
-                string status = agent.IsFinished ? "reached exit" : "exploring";
+                MazeAgent agent = entry.Controller.Agent;
                 bool isFollowed = entry == _followedAgent;
 
-                GUI.color = entry.Color;
-                if (GUILayout.Button($"{(isFollowed ? "> " : "")}{entry.Controller.gameObject.name} - {status}"))
+                GUI.color = agent.IsDead ? new Color(0.55f, 0.55f, 0.55f) : entry.Color;
+                if (GUILayout.Button($"{(isFollowed ? "> " : "")}{agent.DisplayName} - {DescribeStatus(agent)}"))
                     FollowAgent(entry);
             }
             GUI.color = Color.white;
@@ -311,5 +377,153 @@ namespace MazeGen
 
             GUILayout.EndArea();
         }
+
+        private void DrawSimulationControls()
+        {
+            bool paused = SimulationClock.IsPaused;
+            GUILayout.Label(paused ? $"PAUSED ({SimulationClock.HoldSummary ?? "manual"})" : $"Running at {SimulationClock.TimeScale:0.0}x", GUI.skin.box);
+
+            GUILayout.BeginHorizontal();
+            if (GUILayout.Button(SimulationClock.UserPaused ? "Resume (space)" : "Pause (space)"))
+                SimulationClock.UserPaused = !SimulationClock.UserPaused;
+            if (GUILayout.Button("0.5x")) SimulationClock.TimeScale = 0.5f;
+            if (GUILayout.Button("1x")) SimulationClock.TimeScale = 1f;
+            if (GUILayout.Button("4x")) SimulationClock.TimeScale = 4f;
+            GUILayout.EndHorizontal();
+
+            _director.PauseOnFightDeclared = GUILayout.Toggle(_director.PauseOnFightDeclared, " Pause when a fight is declared");
+
+            GUILayout.BeginHorizontal();
+            GUILayout.Label("On join window end:", GUILayout.Width(120f));
+            bool watch = _director.TimeoutResolution == CombatViewMode.Watch;
+            if (GUILayout.Button(watch ? "Watch fight" : "Log only"))
+                _director.TimeoutResolution = watch ? CombatViewMode.Simulate : CombatViewMode.Watch;
+            GUILayout.EndHorizontal();
+        }
+
+        /// <summary>
+        /// Live fights: the join-window countdown plus the controls to watch or fast-resolve.
+        /// Drawn centre-top so a paused fight is impossible to miss.
+        /// </summary>
+        private void DrawFightPanel()
+        {
+            var fights = _director.ActiveFights;
+            if (fights.Count == 0) return;
+
+            float width = Mathf.Min(460f, Mathf.Max(280f, Screen.width - 720f));
+            GUILayout.BeginArea(new Rect(300f, 10f, width, Screen.height - 20f), GUI.skin.box);
+
+            GUILayout.Label($"FIGHTS BREAKING OUT ({fights.Count})", GUI.skin.box);
+
+            // The "resume" button stays outside the scroll view so it's always reachable even
+            // when there are more fights than fit on screen at once.
+            _fightScroll = GUILayout.BeginScrollView(_fightScroll);
+
+            // Snapshot: resuming from this panel can resolve/cancel a fight, mutating the live list.
+            foreach (var fight in fights.ToArray())
+            {
+                GUILayout.BeginVertical(GUI.skin.box);
+                GUILayout.Label($"#{fight.Id} in room {fight.Room.Id} - {fight.Agents.Count} agent(s) vs {fight.Encounter.AliveMonsterCount} monster(s)");
+                GUILayout.Label($"Starts in {Mathf.Max(0f, fight.JoinTimeRemaining):0.0}s - {(SimulationClock.IsPaused ? "frozen; resume to let the timer run out" : "others can still join")}");
+                GUILayout.Label($"In: {string.Join(", ", fight.Agents.Select(a => a.DisplayName))}");
+
+                // These only set a preference for when the join window naturally expires - they
+                // never skip the timer, so late arrivals always get their full chance to join.
+                CombatViewMode preferred = fight.PreferredResolution ?? _director.TimeoutResolution;
+                GUILayout.Label($"When it starts: {(preferred == CombatViewMode.Watch ? "watch it" : "log only")}");
+
+                GUILayout.BeginHorizontal();
+                GUI.enabled = preferred != CombatViewMode.Watch;
+                if (GUILayout.Button("Watch it"))
+                    _director.PreferWatch(fight);
+                GUI.enabled = preferred != CombatViewMode.Simulate;
+                if (GUILayout.Button("Log only"))
+                    _director.PreferSimulate(fight);
+                GUI.enabled = true;
+                if (GUILayout.Button("Go to"))
+                    _cameraController?.FocusOn(fight.Room.Position);
+                GUILayout.EndHorizontal();
+                GUILayout.EndVertical();
+            }
+
+            GUILayout.EndScrollView();
+
+            if (SimulationClock.IsPaused && GUILayout.Button("Resume - let the join window run"))
+            {
+                _director.ResumeAllMusters();
+                SimulationClock.UserPaused = false;
+            }
+
+            GUILayout.EndArea();
+        }
+
+        /// <summary>
+        /// "Agent thoughts and info" for the followed agent: its vitals plus its decision
+        /// narration, newest first, and the shared combat log underneath.
+        /// </summary>
+        private void DrawFollowedAgentPanel()
+        {
+            float width = 400f;
+            float x = Screen.width - width - 10f;
+            float height = Screen.height - 20f;
+
+            GUILayout.BeginArea(new Rect(x, 10f, width, height), GUI.skin.box);
+
+            if (_followedAgent == null)
+            {
+                GUILayout.Label("AGENT THOUGHTS AND INFO", GUI.skin.box);
+                GUILayout.Label("Click an agent on the left to follow it and read what it's thinking.");
+            }
+            else
+            {
+                MazeAgent agent = _followedAgent.Controller.Agent;
+                CharacterSheet sheet = agent.CharacterSheet;
+
+                GUI.color = _followedAgent.Color;
+                GUILayout.Label($"{agent.DisplayName.ToUpperInvariant()} - {DescribeStatus(agent)}", GUI.skin.box);
+                GUI.color = Color.white;
+
+                GUILayout.Label($"Room {agent.CurrentRoom?.Id.ToString() ?? "?"}   New rooms seen: {agent.NewRoomsVisited}   Path: {agent.PathHistory.Count} hop(s)");
+                GUILayout.Label($"HP {sheet.Stats.CurrentHealth:0.#}/{sheet.Stats.MaxHealth:0.#}   ATK {sheet.GetEffectiveAttack():0.#}   DEF {sheet.GetEffectiveDefense():0.#}   SPD {sheet.GetEffectiveSpeed():0.#}");
+                GUILayout.Label($"Weapon: {sheet.EquippedWeapon?.ItemName ?? "none"}   Armor: {sheet.EquippedArmor?.ItemName ?? "none"}");
+
+                Room room = agent.CurrentRoom;
+                int hostiles = room?.Encounter != null ? room.Encounter.AliveMonsterCount : 0;
+                GUILayout.Label(hostiles > 0
+                    ? $"This room: {hostiles} living hostile(s), threat {room.Encounter.GetPerceivedChallengeRating(sheet):0.00}"
+                    : "This room: clear");
+
+                GUILayout.Space(4);
+                GUILayout.Label("Thoughts (newest first):");
+                _thoughtScroll = GUILayout.BeginScrollView(_thoughtScroll, GUILayout.Height(Mathf.Max(120f, height * 0.42f)));
+                var thoughts = agent.Thoughts.Entries;
+                for (int i = thoughts.Count - 1; i >= 0; i--)
+                {
+                    Thought thought = thoughts[i];
+                    GUI.color = AgentThoughtLog.ColorFor(thought.Kind);
+                    GUILayout.Label($"[{thought.Time:0.0}s] {thought.Text}");
+                }
+                GUI.color = Color.white;
+                GUILayout.EndScrollView();
+            }
+
+            GUILayout.Space(4);
+            GUILayout.Label("Combat log (all fights):");
+            _combatLogScroll = GUILayout.BeginScrollView(_combatLogScroll);
+            var log = _director.CombatLog;
+            for (int i = log.Count - 1; i >= 0; i--)
+                GUILayout.Label(log[i]);
+            GUILayout.EndScrollView();
+
+            GUILayout.EndArea();
+        }
+
+        private string DescribeStatus(MazeAgent agent)
+        {
+            if (agent.IsDead) return "dead";
+            if (agent.IsFinished) return "reached exit";
+            if (agent.IsEngaged) return "fighting";
+            return $"exploring ({agent.CharacterSheet.Stats.CurrentHealth:0}hp)";
+        }
     }
 }

+ 146 - 0
Assets/Scripts/Maze/Visualization/MonsterSpawner.cs

@@ -0,0 +1,146 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace MazeGen
+{
+    /// <summary>
+    /// Spawns visual monster GameObjects when rooms with encounters are generated.
+    /// Listens to MazeGenerator's RoomGenerated event and creates MonsterView children
+    /// in each room that has an encounter.
+    ///
+    /// Also keeps the views honest after a fight: <see cref="RefreshRoom"/> removes the views of
+    /// monsters that died, so a cleared room actually looks cleared.
+    /// </summary>
+    public class MonsterSpawner : MonoBehaviour
+    {
+        [SerializeField] private MazeGenerator mazeGenerator;
+
+        // Container transform to organize spawned monsters in the hierarchy
+        private Transform _monstersContainer;
+
+        // Views per room id, so a resolved fight can clean up just that room's dead.
+        private readonly Dictionary<int, List<MonsterView>> _viewsByRoom = new Dictionary<int, List<MonsterView>>();
+
+        // Subscribing in Awake rather than Start: MazeSceneBuilder generates the start rooms in
+        // its own Start, and component Start order on a shared GameObject isn't something to rely
+        // on - subscribing a frame-phase earlier guarantees no room generation is missed.
+        private void Awake()
+        {
+            if (mazeGenerator == null)
+                mazeGenerator = GetComponent<MazeGenerator>();
+
+            if (mazeGenerator == null)
+            {
+                Debug.LogError("[MonsterSpawner] Could not find MazeGenerator!");
+                return;
+            }
+
+            // Create a container for all spawned monsters
+            _monstersContainer = new GameObject("SpawnedMonsters").transform;
+            _monstersContainer.SetParent(transform);
+            _monstersContainer.localPosition = Vector3.zero;
+
+            // Subscribe to room generation event
+            mazeGenerator.RoomGenerated += OnRoomGenerated;
+        }
+
+        private void OnDestroy()
+        {
+            if (mazeGenerator != null)
+                mazeGenerator.RoomGenerated -= OnRoomGenerated;
+        }
+
+        /// <summary>
+        /// Called whenever a new room is generated. If it has an encounter,
+        /// spawn visual monsters in that room.
+        /// </summary>
+        private void OnRoomGenerated(Room room)
+        {
+            if (room.Encounter == null || room.Encounter.Monsters.Count == 0)
+                return;
+
+            SpawnMonstersInRoom(room);
+        }
+
+        /// <summary>
+        /// Drops the views of any monster in this room that is now dead. Wire this to
+        /// CombatDirector.FightResolved so the map reflects the fight's outcome.
+        /// </summary>
+        public void RefreshRoom(Room room)
+        {
+            if (room == null || !_viewsByRoom.TryGetValue(room.Id, out var views)) return;
+
+            for (int i = views.Count - 1; i >= 0; i--)
+            {
+                MonsterView view = views[i];
+                if (view == null)
+                {
+                    views.RemoveAt(i);
+                    continue;
+                }
+
+                if (view.MonsterData != null && view.MonsterData.IsDefeated)
+                {
+                    views.RemoveAt(i);
+                    view.OnDefeated();
+                }
+            }
+
+            if (views.Count == 0)
+                _viewsByRoom.Remove(room.Id);
+        }
+
+        /// <summary>
+        /// Creates visual MonsterView GameObjects scattered around the room.
+        /// </summary>
+        private void SpawnMonstersInRoom(Room room)
+        {
+            var encounter = room.Encounter;
+            int monsterCount = encounter.Monsters.Count;
+
+            // Create a parent for this room's monsters
+            var roomMonstersParent = new GameObject($"Room{room.Id}_Monsters").transform;
+            roomMonstersParent.SetParent(_monstersContainer);
+            roomMonstersParent.localPosition = Vector3.zero;
+
+            var views = new List<MonsterView>(monsterCount);
+            _viewsByRoom[room.Id] = views;
+
+            // Scatter monsters around the room in a circle
+            float angleStep = 360f / monsterCount;
+            float scatterRadius = room.Radius * 0.5f; // Spawn within the room
+
+            for (int i = 0; i < monsterCount; i++)
+            {
+                var monsterData = encounter.Monsters[i];
+                float angle = i * angleStep * Mathf.Deg2Rad;
+                Vector2 offset = new Vector2(
+                    Mathf.Cos(angle) * scatterRadius,
+                    Mathf.Sin(angle) * scatterRadius
+                );
+                Vector2 spawnPos = room.Position + offset;
+
+                views.Add(SpawnMonster(monsterData, spawnPos, roomMonstersParent));
+            }
+        }
+
+        /// <summary>
+        /// Instantiates a single MonsterView at the given position.
+        /// </summary>
+        private MonsterView SpawnMonster(MonsterEncounter.MonsterInstance monsterData, Vector2 position, Transform parent)
+        {
+            var monsterGO = new GameObject(monsterData.Template.MonsterName);
+            monsterGO.transform.SetParent(parent);
+            monsterGO.transform.position = position;
+
+            var monsterView = monsterGO.AddComponent<MonsterView>();
+            monsterView.Initialize(monsterData, position);
+
+            // Optional: add a collider for future click-detection or physics
+            var collider = monsterGO.AddComponent<CircleCollider2D>();
+            collider.radius = 0.15f;
+
+            return monsterView;
+        }
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Visualization/MonsterSpawner.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 892c8132158cf9a488fb71e284f183f6

+ 39 - 0
Assets/Scripts/Maze/Visualization/MonsterView.cs

@@ -0,0 +1,39 @@
+using UnityEngine;
+
+namespace MazeGen
+{
+    /// <summary>
+    /// Visual representation of a single monster instance in a room.
+    /// This is a placeholder using a colored circle - replace with real art later.
+    /// </summary>
+    [RequireComponent(typeof(SpriteRenderer))]
+    public class MonsterView : MonoBehaviour
+    {
+        private MonsterEncounter.MonsterInstance _monsterData;
+
+        public void Initialize(MonsterEncounter.MonsterInstance monsterData, Vector2 position)
+        {
+            _monsterData = monsterData;
+
+            transform.position = position;
+            transform.localScale = Vector3.one * 0.3f; // Smaller than rooms
+
+            var sr = GetComponent<SpriteRenderer>();
+            sr.sprite = RoomView.SharedSquareSprite;
+            sr.color = _monsterData.Template.Color;
+
+            gameObject.name = $"Monster_{_monsterData.Template.MonsterName}";
+        }
+
+        /// <summary>
+        /// Called when this monster is defeated - fade out or destroy visually.
+        /// </summary>
+        public void OnDefeated()
+        {
+            // Simple fade out and destroy (you can add animations later)
+            Destroy(gameObject);
+        }
+
+        public MonsterEncounter.MonsterInstance MonsterData => _monsterData;
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Visualization/MonsterView.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 595fedd179c3065469605dd152f9b02b

+ 77 - 13
CLAUDE.md

@@ -61,11 +61,17 @@ until something tries to go through it. This is what makes "many agents, huge ma
     full rendered length — something can in rare cases still slip past a corridor without being caught.
 
 ### Visualization (`Visualization/`)
-- `MazeSceneBuilder` — the scene entry point (`[RequireComponent(MazeGenerator)]`). Generates the configured
-  start rooms, spawns `agentCount` agents on staggered coroutine delays, wires each agent's
-  `AutonomousAgentBrain`, tracks win conditions (target finished-agent count and/or a time limit — at least one
-  must be set, else it warns and defaults to requiring all agents), and draws a simple IMGUI HUD (elapsed
-  time/finished count, a clickable agent list that makes the camera follow one, a "zoom to fit maze" button).
+- `MazeSceneBuilder` — the scene entry point (requires `MazeGenerator`, `MonsterSpawner`, `CombatDirector`,
+  `BattleArenaView`; it get-or-adds the last two so a scene saved before they existed still works). Generates
+  the configured start rooms, spawns `agentCount` agents on staggered coroutine delays, wires each agent's
+  `AutonomousAgentBrain` and registers it with the `CombatDirector`, tracks win conditions (target
+  finished-agent count and/or a time limit — at least one must be set, else it warns and defaults to requiring
+  all agents; also ends when every agent is dead or finished), and draws the IMGUI HUD: status + simulation
+  controls (pause/speed, pause-on-fight toggle), a clickable agent list, an "agent thoughts and info" panel
+  for the followed agent, a live-fights panel with the join-window countdown and watch/log-only buttons, and
+  the global combat log. It hides itself entirely while a fight is being watched, since the arena draws its own.
+- `MonsterSpawner` — spawns monster views per room and, via `RefreshRoom` (wired to
+  `CombatDirector.FightResolved`), drops the views of monsters that died so a cleared room looks cleared.
 - `RoomView` — placeholder square sprite per room; renders elbow hallways as stretched/rotated rectangles along
   `CorridorWaypoints` instead of a single small square.
 - `ExitMarker` — placeholder door sprite. Purely informational (not clickable — movement is autonomous now):
@@ -75,15 +81,65 @@ until something tries to go through it. This is what makes "many agents, huge ma
 
 ### Agents (`Agent/`)
 - `MazeAgent` — plain C# (no `MonoBehaviour`) state: current room, path history, visited-room/used-exit sets,
-  `IsFinished`. Cheap enough to run many of.
-- `MazeAgentController` — scene-facing wrapper: positions a GameObject, tints it a per-agent color, and drives
-  `TryUseExit` (which calls `MazeGenerator.GenerateConnectedRoom` lazily if the exit isn't resolved yet).
+  `IsFinished`/`IsDead`/`IsEngaged`, and an `AgentThoughtLog`. Cheap enough to run many of.
+- `AgentThoughtLog` — bounded, newest-wins journal of one agent's decisions (`ThoughtKind` categorizes and
+  colors them). Purely an observation surface — the simulation never reads it back — and it's what the HUD's
+  "agent thoughts and info" panel shows for the followed agent.
+- `MazeAgentController` — scene-facing wrapper: positions a GameObject, tints it a per-agent color, dims it
+  on death, and drives `TryUseExit` (which calls `MazeGenerator.GenerateConnectedRoom` lazily if the exit
+  isn't resolved yet). Refuses to move dead or fight-committed agents.
 - `AutonomousAgentBrain` — per-agent decision loop. Waits a randomized initial delay (so agents don't all
-  decide in lockstep), then ticks a steady `decisionInterval`. `ChooseExit` is explore-biased: prefers an
-  unused exit (fresh territory) > a used exit into a room this agent hasn't personally visited (following
-  another agent's discovery) > backtracking into an already-visited room, with `exploreChance` adding
-  randomness. This is the intended extension point for smarter decisions later (e.g. "peek" a room and weigh
-  danger before choosing).
+  decide in lockstep), then ticks a steady `decisionInterval` **on `SimulationClock` time**. Each tick it
+  first deals with hostiles in its current room (`HandleRoomHostiles`: declare a fight, join/sneak past a
+  fight already mustering, or try to slip out), then falls through to movement. `ChooseExit` is explore-biased:
+  prefers an unused exit (fresh territory) > a used exit into a room this agent hasn't personally visited
+  (following another agent's discovery) > backtracking into an already-visited room, with `exploreChance`
+  adding randomness. **Every decision is narrated into the thought log** — adding a decision branch without a
+  matching `Think()` call makes the agent unexplainable to the observer, so keep them together.
+
+### Simulation clock (`Simulation/`)
+- `SimulationClock` — static, ref-counted pause for the whole maze simulation. Anything that advances maze
+  state (agent decision timers, fight join windows, the level's elapsed clock) ticks on
+  `SimulationClock.DeltaTime`, never `Time.deltaTime`. It deliberately does **not** touch `Time.timeScale`,
+  because real Unity time has to keep flowing while the maze is frozen — that's what drives the watched-fight
+  playback and its coroutines. Pauses are held by reason string so several fights (plus an arena playback)
+  can each hold the sim without stomping on each other's resume; the user's manual pause is separate.
+
+### Combat & fights (`Combat/`)
+The fight lifecycle is owned by `CombatDirector`; `CombatResolver` decides outcomes and `BattleArenaView`
+only ever *animates* them.
+
+- `CharacterSheet` / `CharacterStats` / `Items/` / `Monsters/Monster` / `MonsterEncounter` — stats, equipment
+  and per-room monster groups. `MazeGenerator.BuildRoom` rolls an encounter into `room.Encounter` as rooms are
+  generated.
+- `Combatant` — unifies an agent and a monster instance behind one interface so the resolver needn't branch.
+  Wraps the **live** `CharacterSheet` (fights mutate real health, which is how results persist into the maze);
+  `StartHealth` is snapshotted only so playback can animate health draining from where it began.
+- `CombatEvent` / `BattleResult` — a resolved battle is nothing but an ordered event list (hit/miss/death/
+  round). **This is the key seam**: the resolver runs to completion up front, log-only mode prints the list,
+  and the arena replays the same list as animation. The view therefore cannot desync from the simulation, and
+  switching between watching and simulating can never change who wins.
+- `CombatResolver` — multi-participant, all agents vs all monsters in one room. Fixed initiative order by
+  effective speed, hit roll from the speed difference, damage from effective attack vs effective defense.
+  Runs out to a stalemate after `maxRounds` rather than looping forever; survivors of a stalemate mark the
+  room via `MazeAgent.NoteFledFrom` so their brains don't immediately re-declare the same unwinnable fight.
+- `PendingFight` — a declared-but-not-started fight and its join window. This type exists for the window:
+  declaring a fight commits an agent and *then* waits, which is what lets others pile in.
+- `CombatDirector` — the orchestrator. Declaration drags in every agent already standing in that room; during
+  the join window (`joinWindowSeconds`, default 5) arriving agents either join (better odds) or roll to sneak
+  past, with a failed sneak forcing them in; agents elsewhere in the maze can't join. Committed agents are
+  marked `IsEngaged`, which parks their brain. On expiry it resolves the fight — log-only, or handed to the
+  arena — then pushes results back into maze state (dead agents `Die()`, survivors released with their damage
+  intact). `pauseOnFightDeclared` holds the simulation the moment a fight starts so the observer can zoom in.
+
+### Battle arena (`Visualization/BattleArenaView.cs`)
+Plays a fight out on a grid in its **own additively-created scene** (`SceneManager.CreateScene`, unloaded on
+exit — no scene asset or Build Settings wiring involved), built at a far world offset so its camera can't see
+maze geometry. It hides the maze by *suspending the maze cameras*, never by deactivating maze GameObjects —
+`SetActive(false)` on the maze root would permanently kill the agent-spawn coroutines. Because the outcome is
+already decided before playback and health bars run off a private display copy, this class never touches a
+room, agent or character sheet; returning to the maze therefore restores it exactly, with only the fight's
+result changed.
 
 ### Camera & input (`Camera/`, `Input/`)
 - `MazeCameraController` — manual WASD pan / middle-mouse drag, plus `FocusOn` (smooth glide, used only while
@@ -101,3 +157,11 @@ until something tries to go through it. This is what makes "many agents, huge ma
 - All visuals (`RoomView`, `ExitMarker`, agent sprites) are intentionally flat placeholder sprites sharing
   `RoomView.SharedSquareSprite` — swappable for real art later without touching generation logic.
 - Generation must stay lazy: don't pre-generate rooms beyond an unused exit "just in case."
+- Anything that advances maze state must tick on `SimulationClock.DeltaTime`, not `Time.deltaTime` — a
+  half-paused simulation is worse than none. Equally, don't "fix" pausing by setting `Time.timeScale`: the
+  watched-fight playback depends on real time still running.
+- Fight outcomes are decided once, up front, by `CombatResolver` and then replayed. Don't move outcome
+  decisions into `BattleArenaView` or make it mutate character sheets — that's what keeps watched and
+  simulated fights identical, and what guarantees the maze is untouched when a fight ends.
+- Don't hide the maze during a watched fight by deactivating the maze root GameObject: `SetActive(false)`
+  permanently kills the in-flight agent-spawn coroutines. Suspend the cameras instead.

+ 3 - 26
ProjectSettings/TagManager.asset

@@ -2,8 +2,9 @@
 %TAG !u! tag:unity3d.com,2011:
 --- !u!78 &1
 TagManager:
-  serializedVersion: 2
-  tags: []
+  serializedVersion: 3
+  tags:
+  - Agent
   layers:
   - Default
   - TransparentFX
@@ -50,27 +51,3 @@ TagManager:
   - Light Layer 5
   - Light Layer 6
   - Light Layer 7
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 
-  - 

+ 3 - 0
UserSettings/EditorUserSettings.asset

@@ -6,6 +6,9 @@ EditorUserSettings:
   serializedVersion: 4
   m_ConfigSettings:
     RecentlyUsedSceneGuid-0:
+      value: 02080c53030c080c0b0c5b7247250b44414e1d287b7f76367a794b31b0b36d69
+      flags: 0
+    RecentlyUsedSceneGuid-1:
       value: 5a08575f5207595a0f5d59741173094444164f7d7d2a23317c7a4465bbe1646d
       flags: 0
     vcSharedLogLevel:

+ 44 - 34
UserSettings/Layouts/default-6000.dwlt

@@ -19,7 +19,7 @@ MonoBehaviour:
     width: 3440
     height: 1349
   m_ShowMode: 4
-  m_Title: Game
+  m_Title: Hierarchy
   m_RootView: {fileID: 2}
   m_MinSize: {x: 875, y: 300}
   m_MaxSize: {x: 10000, y: 10000}
@@ -119,7 +119,7 @@ MonoBehaviour:
   m_MinSize: {x: 400, y: 112}
   m_MaxSize: {x: 32384, y: 16192}
   vertical: 0
-  controlID: 48
+  controlID: 49
   draggingID: 0
 --- !u!114 &6
 MonoBehaviour:
@@ -145,7 +145,7 @@ MonoBehaviour:
   m_MinSize: {x: 300, y: 112}
   m_MaxSize: {x: 24288, y: 16192}
   vertical: 1
-  controlID: 49
+  controlID: 50
   draggingID: 0
 --- !u!114 &7
 MonoBehaviour:
@@ -172,7 +172,7 @@ MonoBehaviour:
   m_MinSize: {x: 300, y: 56}
   m_MaxSize: {x: 24288, y: 8096}
   vertical: 0
-  controlID: 50
+  controlID: 51
   draggingID: 0
 --- !u!114 &8
 MonoBehaviour:
@@ -191,7 +191,7 @@ MonoBehaviour:
     serializedVersion: 2
     x: 0
     y: 0
-    width: 594
+    width: 593
     height: 951
   m_MinSize: {x: 201, y: 226}
   m_MaxSize: {x: 4001, y: 4026}
@@ -215,9 +215,9 @@ MonoBehaviour:
   m_Children: []
   m_Position:
     serializedVersion: 2
-    x: 594
+    x: 593
     y: 0
-    width: 1072
+    width: 1073
     height: 951
   m_MinSize: {x: 202, y: 226}
   m_MaxSize: {x: 4002, y: 4026}
@@ -279,7 +279,7 @@ MonoBehaviour:
   m_MinSize: {x: 200, y: 56}
   m_MaxSize: {x: 16192, y: 8096}
   vertical: 0
-  controlID: 125
+  controlID: 127
   draggingID: 0
 --- !u!114 &12
 MonoBehaviour:
@@ -751,7 +751,7 @@ MonoBehaviour:
     serializedVersion: 2
     x: 0
     y: 24
-    width: 593
+    width: 592
     height: 925
   m_SerializedDataModeController:
     m_DataMode: 0
@@ -773,12 +773,10 @@ MonoBehaviour:
       m_LastClickedID:
         m_Data: 0
       m_ExpandedIDs:
-      - m_Data: -818808
-      - m_Data: -818668
-      - m_Data: -451282
-      - m_Data: -451274
+      - m_Data: -62890
+      - m_Data: -60530
       - m_Data: -1298
-      - m_Data: 45798
+      - m_Data: 45820
       m_RenameOverlay:
         m_UserAcceptedRename: 0
         m_Name: 
@@ -825,9 +823,9 @@ MonoBehaviour:
     m_TextWithWhitespace: "Scene\u200B"
   m_Pos:
     serializedVersion: 2
-    x: 595
+    x: 594
     y: 24
-    width: 1070
+    width: 1071
     height: 925
   m_SerializedDataModeController:
     m_DataMode: 0
@@ -1441,9 +1439,9 @@ MonoBehaviour:
   m_AudioPlay: 0
   m_DebugDrawModesUseInteractiveLightBakingData: 0
   m_Position:
-    m_Target: {x: 1.3210471, y: 1.6667428, z: -0.3746831}
+    m_Target: {x: 15.248607, y: -1.5061598, z: 0}
     speed: 2
-    m_Value: {x: 1.3210471, y: 1.6667428, z: -0.3746831}
+    m_Value: {x: 15.248607, y: -1.5061598, z: 0}
   m_RenderMode: 0
   m_CameraMode:
     drawMode: 0
@@ -1489,13 +1487,13 @@ MonoBehaviour:
     m_GridAxis: 1
     m_gridOpacity: 0.5
   m_Rotation:
-    m_Target: {x: 0.00044787914, y: 0.99879736, z: -0.048288397, w: 0.0092900805}
+    m_Target: {x: 0.00040607058, y: 0.99900454, z: -0.043793336, w: 0.009292002}
     speed: 2
-    m_Value: {x: 0.00044787914, y: 0.99879736, z: -0.048288397, w: 0.0092900805}
+    m_Value: {x: 0.00040607058, y: 0.99900454, z: -0.043793336, w: 0.009292002}
   m_Size:
-    m_Target: 3.0185056
+    m_Target: 22.27303
     speed: 2
-    m_Value: 3.0185056
+    m_Value: 22.27303
   m_Ortho:
     m_Target: 0
     speed: 2
@@ -1712,7 +1710,7 @@ MonoBehaviour:
     m_SkipHidden: 0
     m_SearchArea: 0
     m_Folders:
-    - Assets/Scripts/Maze/Agent
+    - Packages/com.unity.shadergraph/GraphTemplates/Subgraphs
     m_Globs: []
     m_ProductIds: 
     m_AnyWithAssetOrigin: 0
@@ -1722,23 +1720,29 @@ MonoBehaviour:
   m_ViewMode: 1
   m_StartGridSize: 67
   m_LastFolders:
-  - Assets/Scripts/Maze/Agent
+  - Packages/com.unity.shadergraph/GraphTemplates/Subgraphs
   m_LastFoldersGridSize: 67
   m_LastProjectPath: C:\Users\Axel-PC\MazeGenerator
   m_LockTracker:
     m_IsLocked: 0
   m_LastLocalAssetsSearchArea: 0
   m_FolderTreeState:
-    scrollPos: {x: 0, y: 12}
+    scrollPos: {x: 0, y: 456}
     m_SelectedIDs:
-    - m_Data: 46194
+    - m_Data: 46238
     m_LastClickedID:
-      m_Data: 46194
+      m_Data: 46238
     m_ExpandedIDs:
     - m_Data: 0
-    - m_Data: 45882
-    - m_Data: 45884
-    - m_Data: 45886
+    - m_Data: 45912
+    - m_Data: 45914
+    - m_Data: 45916
+    - m_Data: 45918
+    - m_Data: 45920
+    - m_Data: 45922
+    - m_Data: 45924
+    - m_Data: 45926
+    - m_Data: 45928
     m_RenameOverlay:
       m_UserAcceptedRename: 0
       m_Name: 
@@ -1771,9 +1775,15 @@ MonoBehaviour:
       m_Data: 0
     m_ExpandedIDs:
     - m_Data: 0
-    - m_Data: 45882
-    - m_Data: 45884
-    - m_Data: 45886
+    - m_Data: 45912
+    - m_Data: 45914
+    - m_Data: 45916
+    - m_Data: 45918
+    - m_Data: 45920
+    - m_Data: 45922
+    - m_Data: 45924
+    - m_Data: 45926
+    - m_Data: 45928
     m_RenameOverlay:
       m_UserAcceptedRename: 0
       m_Name: 
@@ -1825,7 +1835,7 @@ MonoBehaviour:
       m_OriginalEventType: 11
       m_IsRenamingFilename: 1
       m_TrimLeadingAndTrailingWhitespace: 0
-      m_ClientGUIView: {fileID: 12}
+      m_ClientGUIView: {fileID: 9}
     m_CreateAssetUtility:
       m_EndAction: {fileID: 0}
       m_InstanceID: 0

+ 50 - 0
UserSettings/Search.settings

@@ -11,6 +11,56 @@ showStatusBar = false
 scopes = {
 }
 providers = {
+	asset = {
+		active = true
+		priority = 25
+		defaultAction = null
+	}
+	scene = {
+		active = true
+		priority = 50
+		defaultAction = null
+	}
+	adb = {
+		active = false
+		priority = 2500
+		defaultAction = null
+	}
+	presets_provider = {
+		active = false
+		priority = -10
+		defaultAction = null
+	}
+	find = {
+		active = true
+		priority = 25
+		defaultAction = null
+	}
+	packages = {
+		active = false
+		priority = 90
+		defaultAction = null
+	}
+	performance = {
+		active = false
+		priority = 100
+		defaultAction = null
+	}
+	profilermarkers = {
+		active = false
+		priority = 100
+		defaultAction = null
+	}
+	store = {
+		active = false
+		priority = 100
+		defaultAction = null
+	}
+	log = {
+		active = false
+		priority = 210
+		defaultAction = null
+	}
 }
 objectSelectors = {
 }