using System.Collections.Generic; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Spawning; using BumpCombat.Stage; using NUnit.Framework; using UnityEngine; namespace BumpCombat.Tests { public sealed class LevelPresetRuntimeTests { private GameObject stageManagerObject; private StageManager stageManager; private GameObject spawnDirectorObject; private SpawnDirector spawnDirector; private GameObject playerObject; private PlayerController player; [SetUp] public void SetUp() { stageManagerObject = new GameObject("StageManager_RuntimeTest"); stageManager = stageManagerObject.AddComponent(); spawnDirectorObject = new GameObject("SpawnDirector_RuntimeTest"); spawnDirector = spawnDirectorObject.AddComponent(); playerObject = new GameObject("Player_RuntimeTest"); player = playerObject.AddComponent(); // Setup mock enemy prefabs on SpawnDirector via reflection SetupMockEnemyPrefabs(); } [TearDown] public void TearDown() { if (stageManagerObject != null) Object.DestroyImmediate(stageManagerObject); if (spawnDirectorObject != null) Object.DestroyImmediate(spawnDirectorObject); if (playerObject != null) Object.DestroyImmediate(playerObject); ArenaBounds.Instance?.ResetToDefaultBounds(); } private void SetupMockEnemyPrefabs() { #if UNITY_EDITOR EnemyController batPrefab = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/_Project/Prefabs/Enemies/Bat.prefab"); EnemyController skelPrefab = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/_Project/Prefabs/Enemies/Skeleton.prefab"); if (batPrefab != null && skelPrefab != null) { var field = typeof(SpawnDirector).GetField("enemyPrefabs", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); field?.SetValue(spawnDirector, new EnemyController[] { skelPrefab, batPrefab }); } #endif } private ZoneInstance CreateZoneInstanceWithS1A(int nodeId, Vector2 worldPos) { return CreateZoneInstanceWithPreset("GRAVE_S1_COMBAT_A", nodeId, worldPos); } private ZoneInstance CreateZoneInstanceWithPreset( string presetId, int nodeId, Vector2 worldPos, bool connectEast = true) { StageNode node = new StageNode { Id = nodeId, NodeType = StageNodeType.Combat, PresetId = presetId, GridPosition = Vector2Int.zero, }; node.SetConnection(StageDirection.North, 2); node.SetConnection(StageDirection.South, 3); node.SetConnection(StageDirection.West, 4); if (connectEast) { node.SetConnection(StageDirection.East, 5); } GameObject zoneObj = new GameObject($"Zone_{nodeId}_Combat"); zoneObj.transform.position = worldPos; zoneObj.transform.SetParent(stageManager.transform); ZoneInstance zone = zoneObj.AddComponent(); zone.Initialize(node, new Vector2(9f, 6f)); // Call private BuildZoneGeometry via reflection var method = typeof(StageManager).GetMethod("BuildZoneGeometry", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); method?.Invoke(stageManager, new object[] { zoneObj, zone, node }); return zone; } [Test] public void ZoneConnector_ExistingBarrierObjectWithoutCollider_AddsBoxCollider2D() { GameObject connectorObject = new GameObject("ZoneConnector_RuntimeTest"); connectorObject.transform.SetParent(stageManager.transform); connectorObject.AddComponent(); GameObject barrierObject = new GameObject("BarrierCollider"); barrierObject.transform.SetParent(connectorObject.transform, false); connectorObject.AddComponent(); Assert.IsNotNull(barrierObject.GetComponent()); } [Test] public void S1A_Geometry_EmptyCellsBlocked_And_ObstaclesPlacedCorrectly() { ZoneInstance zone = CreateZoneInstanceWithS1A(10, Vector2.zero); Assert.IsNotNull(zone); // S1-A (18x12 = 216 cells). Count empty and obstacle objects int emptyColliderCount = 0; int obstacleColliderCount = 0; for (int i = 0; i < zone.transform.childCount; i++) { Transform child = zone.transform.GetChild(i); if (child.name.StartsWith("EmptyCell_")) emptyColliderCount++; if (child.name.StartsWith("Obstacle_")) obstacleColliderCount++; } // Approved S1-A layout uses five broken cover cells. Assert.AreEqual(5, obstacleColliderCount, "S1-A must have exactly 5 obstacle objects"); // Approved S1-A obstacle cells. Assert.IsNotNull(zone.transform.Find("Obstacle_6_3")); Assert.IsNotNull(zone.transform.Find("Obstacle_6_4")); Assert.IsNotNull(zone.transform.Find("Obstacle_12_4")); Assert.IsNotNull(zone.transform.Find("Obstacle_10_6")); Assert.IsNotNull(zone.transform.Find("Obstacle_10_7")); // Empty cells must have colliders to block entry Assert.Greater(emptyColliderCount, 50, "Empty cells must have blocking colliders"); Assert.IsNotNull(zone.transform.Find("EmptyCell_0_0"), "Corner (0,0) must be blocked"); Assert.IsNotNull(zone.transform.Find("EmptyCell_17_0"), "Corner (17,0) must be blocked"); Assert.IsNotNull(zone.transform.Find("EmptyCell_0_11"), "Corner (0,11) must be blocked"); Assert.IsNotNull(zone.transform.Find("EmptyCell_17_11"), "Corner (17,11) must be blocked"); } [Test] public void S1AExpanded_Geometry_UsesOneUnitCellsAndOneCellDoors() { ZoneInstance zone = CreateZoneInstanceWithPreset( LevelPresetCatalog.S1AComparisonExpandedPresetId, 11, Vector2.zero); Assert.IsNotNull(zone); Assert.AreEqual(36, zone.Preset.GridWidth); Assert.AreEqual(24, zone.Preset.GridHeight); int obstacleCount = 0; for (int i = 0; i < zone.transform.childCount; i++) { Transform child = zone.transform.GetChild(i); if (!child.name.StartsWith("Obstacle_")) { continue; } obstacleCount++; BoxCollider2D collider = child.GetComponent(); Assert.IsNotNull(collider); Assert.AreEqual(new Vector2(1f, 1f), collider.size); } Assert.AreEqual(8, obstacleCount); foreach (StageDirection direction in System.Enum.GetValues(typeof(StageDirection))) { ZoneConnector connector = zone.GetConnector(direction); Assert.IsNotNull(connector, $"Expanded S1-A must have {direction} connector"); BoxCollider2D trigger = connector.GetComponent(); Assert.AreEqual(new Vector2(1f, 1f), trigger.size, $"Expanded S1-A {direction} door must remain one cell wide and one cell deep"); } } [Test] public void S1A_PerimeterAndSockets_MatchPhysicalSocketCentersAndWidths() { ZoneInstance zone = CreateZoneInstanceWithS1A(10, Vector2.zero); Assert.IsNotNull(zone); // W1 Socket: West, cell (0, 4), width 1. // Physical outer opening: x = -9.0f, y = 1.5f. Trigger size: (1.5f, 1.0f) ZoneConnector w1Connector = zone.GetConnector("W1"); Assert.IsNotNull(w1Connector, "W1 connector must exist"); Assert.AreEqual(StageDirection.West, w1Connector.Direction); Assert.AreEqual(-9.0f, w1Connector.transform.localPosition.x, 0.01f); Assert.AreEqual(1.5f, w1Connector.transform.localPosition.y, 0.01f); BoxCollider2D w1Trigger = w1Connector.GetComponent(); Assert.AreEqual(1.0f, w1Trigger.size.y, 0.01f); // E1 Socket: East, cell (17, 4), width 1. // Physical outer opening: x = 9.0f, y = 1.5f. Trigger size: (1.5f, 1.0f) ZoneConnector e1Connector = zone.GetConnector("E1"); Assert.IsNotNull(e1Connector, "E1 connector must exist"); Assert.AreEqual(StageDirection.East, e1Connector.Direction); Assert.AreEqual(9.0f, e1Connector.transform.localPosition.x, 0.01f); Assert.AreEqual(1.5f, e1Connector.transform.localPosition.y, 0.01f); BoxCollider2D e1Trigger = e1Connector.GetComponent(); Assert.AreEqual(1.0f, e1Trigger.size.y, 0.01f); // N1 Socket: North, cell (7, 0), width 1. // Physical outer opening: x = -1.5f, y = 6.0f. Trigger size: (1.0f, 1.5f) ZoneConnector n1Connector = zone.GetConnector("N1"); Assert.IsNotNull(n1Connector, "N1 connector must exist"); Assert.AreEqual(StageDirection.North, n1Connector.Direction); Assert.AreEqual(-1.5f, n1Connector.transform.localPosition.x, 0.01f); Assert.AreEqual(6.0f, n1Connector.transform.localPosition.y, 0.01f); BoxCollider2D n1Trigger = n1Connector.GetComponent(); Assert.AreEqual(1.0f, n1Trigger.size.x, 0.01f); // S1 Socket: South, cell (7, 11), width 1. // Physical outer opening: x = -1.5f, y = -6.0f. Trigger size: (1.0f, 1.5f) ZoneConnector s1Connector = zone.GetConnector("S1"); Assert.IsNotNull(s1Connector, "S1 connector must exist"); Assert.AreEqual(StageDirection.South, s1Connector.Direction); Assert.AreEqual(-1.5f, s1Connector.transform.localPosition.x, 0.01f); Assert.AreEqual(-6.0f, s1Connector.transform.localPosition.y, 0.01f); BoxCollider2D s1Trigger = s1Connector.GetComponent(); Assert.AreEqual(1.0f, s1Trigger.size.x, 0.01f); } [Test] public void S3D_InactiveEastSocket_CorridorBeyondDeclaredCellsIsClosed() { ZoneInstance zone = CreateZoneInstanceWithPreset( "GRAVE_S3_FINAL_NECROMANCER", 30, Vector2.zero, false); RoomPreset preset = zone.Preset; Assert.IsNotNull(preset); Assert.AreEqual(PresetCellType.Empty, preset.GridMap[22, 7]); Assert.AreEqual(PresetCellType.Empty, preset.GridMap[23, 7]); for (int y = 7; y <= 9; y++) { Assert.AreEqual(PresetCellType.Empty, zone.GetRuntimeCell(21, y), $"Inactive E1 declared cell (21,{y}) must be closed."); Assert.AreEqual(PresetCellType.Empty, zone.GetRuntimeCell(22, y), $"Inactive E1 outward cell (22,{y}) must be closed."); Assert.AreEqual(PresetCellType.Empty, zone.GetRuntimeCell(23, y), $"Inactive E1 outward cell (23,{y}) must be closed."); } } [Test] public void S1A_EncounterA_SpawnsExactBat3AndSkeleton2_WithSeparation() { ZoneInstance zone = CreateZoneInstanceWithS1A(10, Vector2.zero); // Set player at W1 entry position Vector2 entryPos = zone.GetEntryPosition(StageDirection.West); player.transform.position = entryPos; // Spawn encounter spawnDirector.SpawnZoneEncounter(zone); // Canonical S1-A Encounter A: Bat 3 + Skeleton 2 = 5 enemies total Assert.AreEqual(5, zone.SpawnedEnemies.Count, "Canonical Encounter A must spawn exactly 5 enemies"); int batCount = 0; int skeletonCount = 0; foreach (var enemy in zone.SpawnedEnemies) { if (enemy.name.StartsWith("Bat")) batCount++; if (enemy.name.StartsWith("Skeleton")) skeletonCount++; } Assert.AreEqual(3, batCount, "Canonical S1-A must have exactly 3 Bat enemies"); Assert.AreEqual(2, skeletonCount, "Must have exactly 2 Skeleton enemies"); // Verify no overlapping colliders (separation >= 0.8 units) for (int i = 0; i < zone.SpawnedEnemies.Count; i++) { for (int j = i + 1; j < zone.SpawnedEnemies.Count; j++) { float dist = Vector2.Distance(zone.SpawnedEnemies[i].transform.position, zone.SpawnedEnemies[j].transform.position); Assert.GreaterOrEqual(dist, 0.8f, $"Enemies {i} and {j} must not overlap (dist={dist})"); } } } [Test] public void S1AExpanded_EncounterA_DoublesCountsAndKeepsGroupsBounded() { ZoneInstance zone = CreateZoneInstanceWithPreset( LevelPresetCatalog.S1AComparisonExpandedPresetId, 12, Vector2.zero); player.transform.position = zone.GetEntryPosition(StageDirection.West); spawnDirector.SpawnZoneEncounter(zone); Assert.AreEqual(10, zone.SpawnedEnemies.Count, "Expanded Encounter A must spawn Bat 6 + Skeleton 4"); int batCount = 0; int skeletonCount = 0; Dictionary groupCounts = new(); foreach (EnemyController enemy in zone.SpawnedEnemies) { if (enemy.name.StartsWith("Bat")) batCount++; if (enemy.name.StartsWith("Skeleton")) skeletonCount++; if (!groupCounts.ContainsKey(enemy.EncounterGroupId)) { groupCounts[enemy.EncounterGroupId] = 0; } groupCounts[enemy.EncounterGroupId]++; } Assert.AreEqual(6, batCount); Assert.AreEqual(4, skeletonCount); foreach (int groupCount in groupCounts.Values) { Assert.That(groupCount, Is.InRange(1, 3), "Expanded encounters must retain the existing 1-3 member group rule"); } } [Test] public void S1A_EntrySafeDistance_GuaranteedFromWestEntrySocket() { ZoneInstance zone = CreateZoneInstanceWithS1A(10, Vector2.zero); // Position player at W1 entry position (inward offset 2.5f from socket) Vector2 playerEntryPos = zone.GetEntryPosition(StageDirection.West); player.transform.position = playerEntryPos; spawnDirector.SpawnZoneEncounter(zone); // Verify safe distance for each enemy foreach (var enemy in zone.SpawnedEnemies) { float dist = Vector2.Distance(playerEntryPos, enemy.transform.position); if (enemy.name.StartsWith("Bat")) { // Fast / Crowd enemy requires >= 5.0 units Assert.GreaterOrEqual(dist, 5.0f - 0.01f, $"Bat at {enemy.transform.position} must be >= 5.0 units from player {playerEntryPos} (dist={dist})"); } else if (enemy.name.StartsWith("Skeleton")) { // Normal melee enemy requires >= 4.0 units Assert.GreaterOrEqual(dist, 4.0f - 0.01f, $"Skeleton at {enemy.transform.position} must be >= 4.0 units from player {playerEntryPos} (dist={dist})"); } } } [Test] public void S1A_Breakthrough_AllowsSocketCrossingWithAliveEnemies_AndPreservesEnemies() { stageManager.StartRun(9999); // Create S1-A combat zone and adjacent exit zone ZoneInstance combatZone = CreateZoneInstanceWithS1A(100, Vector2.zero); ZoneInstance nextZone = CreateZoneInstanceWithS1A(101, new Vector2(36f, 0f)); // Register zones in stageManager's zoneMap via reflection var zoneMapField = typeof(StageManager).GetField("zoneMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var zoneMap = (Dictionary)zoneMapField.GetValue(stageManager); zoneMap[100] = combatZone; zoneMap[101] = nextZone; // Set currentZone to combatZone var currentZoneField = typeof(StageManager).GetField("currentZone", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); currentZoneField.SetValue(stageManager, combatZone); // Activate combat zone combatZone.ActivateZone(); spawnDirector.SpawnZoneEncounter(combatZone); // Canonical S1-A Encounter A has 5 enemies alive Assert.AreEqual(5, combatZone.SpawnedEnemies.Count); Assert.IsFalse(combatZone.IsCleared, "Combat zone is not cleared yet"); // Verify normal combat zone exit socket is NOT locked (Breakthrough allowed) ZoneConnector e1Connector = combatZone.GetConnector("E1"); Assert.IsNotNull(e1Connector); Assert.IsFalse(e1Connector.IsLocked, "Normal combat room exit must not be locked when enemies are alive"); // Player requests transition through E1 with enemies alive stageManager.RequestZoneTransition(101, StageDirection.West); // Current zone switched to nextZone Assert.AreEqual(101, stageManager.CurrentZone.NodeId); // Previous combat zone enemies must be preserved (still 5 alive, but deactivated) Assert.AreEqual(5, combatZone.SpawnedEnemies.Count); foreach (var enemy in combatZone.SpawnedEnemies) { Assert.IsNotNull(enemy); Assert.IsFalse(enemy.gameObject.activeSelf, "Previous zone enemies must be deactivated while outside"); } Assert.IsFalse(combatZone.IsCleared, "Bypassed room remains uncleared"); // Return to combat zone stageManager.RequestZoneTransition(100, StageDirection.East); Assert.AreEqual(100, stageManager.CurrentZone.NodeId); // Enemies reactivated upon re-entry foreach (var enemy in combatZone.SpawnedEnemies) { Assert.IsTrue(enemy.gameObject.activeSelf, "Enemies must be reactivated upon re-entering room"); } } [Test] public void S1A_Visuals_LinesUsedForBoundariesAndObstacles_NoStretchedVisuals() { ZoneInstance zone = CreateZoneInstanceWithS1A(20, Vector2.zero); Assert.IsNotNull(zone); // Verify perimeter walls use BoundaryLine with line thickness (not stretched GraveSlab) bool foundBoundaryLine = false; for (int i = 0; i < zone.transform.childCount; i++) { Transform child = zone.transform.GetChild(i); if (child.name.StartsWith("Wall_")) { Transform boundaryLine = child.Find("BoundaryLine"); if (boundaryLine != null) { foundBoundaryLine = true; // Either x or y must be thin line (0.08f) Assert.IsTrue(Mathf.Approximately(boundaryLine.localScale.x, 0.08f) || Mathf.Approximately(boundaryLine.localScale.y, 0.08f), "Boundary line must have 0.08f thickness"); } } } Assert.IsTrue(foundBoundaryLine, "At least one wall must contain BoundaryLine"); // Verify obstacles use line segments (Fill / Line_N / Line_S / etc.) instead of stretched GraveSlab bool foundObstacleLines = false; for (int i = 0; i < zone.transform.childCount; i++) { Transform child = zone.transform.GetChild(i); if (child.name.StartsWith("Obstacle_")) { Transform fill = child.Find("Fill"); Assert.IsNotNull(fill, "Obstacle must have semi-transparent fill"); foundObstacleLines = true; } } Assert.IsTrue(foundObstacleLines, "Obstacles must be rendered with Fill and outline lines"); } [Test] public void S1A_Encounter_PrioritizesMeleeAnchors_BeforeCrowd() { ZoneInstance zone = CreateZoneInstanceWithS1A(21, Vector2.zero); spawnDirector.SpawnZoneEncounter(zone); Assert.AreEqual(5, zone.SpawnedEnemies.Count); // Skeletons (Melee) should occupy authored NormalMelee anchors RoomPreset preset = zone.Preset; Vector2 m01Pos = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(4, 3)); Vector2 m02Pos = preset.CellToWorldPosition(zone.ZoneCenter, new Vector2Int(10, 2)); bool skeletonAtM01 = false; bool skeletonAtM02 = false; foreach (var enemy in zone.SpawnedEnemies) { if (enemy.name.StartsWith("Skeleton")) { if (Vector2.Distance(enemy.transform.position, m01Pos) < 0.1f) skeletonAtM01 = true; if (Vector2.Distance(enemy.transform.position, m02Pos) < 0.1f) skeletonAtM02 = true; } } Assert.IsTrue(skeletonAtM01 || skeletonAtM02, "At least one Skeleton must occupy a priority NormalMelee anchor (M01 or M02)"); } } }