using System.Collections; using System.Reflection; using BumpCombat.Constants; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using BumpCombat.Stage; using BumpCombat.UI; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; using UnityEngine.UI; namespace BumpCombat.PlayModeTests { public sealed class FiniteArenaPlayModeTests { [TearDown] public void Cleanup() { ArtifactRewardController.GrantCatalogForTests = false; Time.timeScale = 1f; } [UnityTest] public IEnumerator PauseFreezesCameraFollowUntilPlayResumes() { yield return LoadAndStartRun(); RunManager runManager = RunManager.Instance; PlayerController player = Object.FindAnyObjectByType(); ArenaCameraFollow follow = Object.FindAnyObjectByType(); ArenaBounds bounds = Object.FindAnyObjectByType(); Assert.That(player, Is.Not.Null); Assert.That(follow, Is.Not.Null); Assert.That(bounds, Is.Not.Null); player.TryReposition(bounds.SharedActorClampHalfExtents); yield return null; Assert.That(runManager.TryOpenPause(), Is.True); Vector2 pausedCenter = follow.CurrentCenter; yield return new WaitForSecondsRealtime(0.3f); Assert.That(follow.CurrentCenter, Is.EqualTo(pausedCenter)); Assert.That(runManager.ClosePause(), Is.True); yield return new WaitForSecondsRealtime(0.8f); Assert.That(follow.CurrentCenter.x, Is.GreaterThan(0.1f)); Assert.That(follow.CurrentCenter.y, Is.GreaterThan(0.1f)); } [UnityTest] public IEnumerator RangedEnemyAtCornerApproachesBeforeAttackStart() { yield return LoadAndStartRun(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerController player = Object.FindAnyObjectByType(); PlayerHealth playerHealth = Object.FindAnyObjectByType(); Camera camera = Camera.main; ArenaCameraFollow follow = Object.FindAnyObjectByType(); ArenaBounds bounds = Object.FindAnyObjectByType(); Assert.That(playerHealth, Is.Not.Null); Assert.That(camera, Is.Not.Null); Assert.That(follow, Is.Not.Null); Assert.That(bounds, Is.Not.Null); playerHealth.GrantInvulnerability(10f); yield return DestroyActiveEnemies(); camera.aspect = 16f / 9f; EnemyController rangedPrefab = FindRangedPrefab(director); Assert.That(rangedPrefab, Is.Not.Null, "A ranged prefab is required for this regression."); Vector2 halfExtents = bounds.SharedActorClampHalfExtents; Vector2[] edgePositions = { new(halfExtents.x, 0f), new(-halfExtents.x, 0f), new(0f, halfExtents.y), new(0f, -halfExtents.y), }; for (int edgeIndex = 0; edgeIndex < edgePositions.Length; edgeIndex++) { Vector2 edgePosition = edgePositions[edgeIndex]; player.TryReposition(edgePosition); // Let the real follow path settle before the enemy is created // so the visible gate is measured from that edge camera. yield return new WaitForSecondsRealtime(0.9f); EnemyController ranged = Object.Instantiate( rangedPrefab, edgePosition, Quaternion.identity); yield return null; float gateDeadline = Time.realtimeSinceStartup + 3f; while (!ranged.IsRangedAttackStartAllowed && Time.realtimeSinceStartup < gateDeadline) { yield return new WaitForFixedUpdate(); } Vector2 position = ranged.GetComponent().position; Assert.That( ranged.IsRangedAttackStartAllowed, Is.True, $"Ranged enemy remained outside its attack gate at edge={edgeIndex}, " + $"position={position}; camera={camera.transform.position}."); Vector2 rangedGateHalfExtents = bounds.GetReachableHalfExtents(0.75f); Assert.That( Mathf.Abs(position.x), Is.LessThanOrEqualTo(rangedGateHalfExtents.x + 0.001f)); Assert.That( Mathf.Abs(position.y), Is.LessThanOrEqualTo(rangedGateHalfExtents.y + 0.001f)); float attackDeadline = Time.realtimeSinceStartup + 1.5f; while (ranged.State != EnemyState.Warning && ranged.State != EnemyState.Active && Time.realtimeSinceStartup < attackDeadline) { yield return null; } Assert.That( ranged.State == EnemyState.Warning || ranged.State == EnemyState.Active, Is.True, $"Ranged enemy entered the interior gate but did not start an attack " + $"(edge={edgeIndex}, state={ranged.State}, position={position})."); Object.Destroy(ranged.gameObject); yield return null; } } [UnityTest] public IEnumerator LargeHorizontalMeleeAtFiniteArenaEdges_ReachesActiveAttack() { yield return LoadAndStartRun(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerController player = Object.FindAnyObjectByType(); PlayerHealth playerHealth = Object.FindAnyObjectByType(); Rigidbody2D playerBody = player?.GetComponent(); ArenaBounds bounds = Object.FindAnyObjectByType(); Assert.That(player, Is.Not.Null); Assert.That(playerHealth, Is.Not.Null); Assert.That(playerBody, Is.Not.Null); Assert.That(bounds, Is.Not.Null); playerHealth.GrantInvulnerability(60f); yield return DestroyActiveEnemies(); Vector2 halfExtents = bounds.SharedActorClampHalfExtents; Vector2[] playerPositions = { new(0f, -halfExtents.y), new(0f, halfExtents.y), new(-halfExtents.x, 0f), new(halfExtents.x, 0f), new(-halfExtents.x, -halfExtents.y), new(halfExtents.x, -halfExtents.y), new(-halfExtents.x, halfExtents.y), new(halfExtents.x, halfExtents.y), }; EnemyKind[] kinds = { EnemyKind.ArmoredSkeleton, EnemyKind.GreatswordSkeleton, }; RunTimedEvent[] roles = { RunTimedEvent.Elite, RunTimedEvent.MidBoss, }; float[] scaleMultipliers = { 1.25f, 1.6f }; for (int kindIndex = 0; kindIndex < kinds.Length; kindIndex++) { EnemyController prefab = FindCatalogPrefab( director, kinds[kindIndex]); Assert.That( prefab, Is.Not.Null, $"Missing edge melee prefab for {kinds[kindIndex]}."); for (int positionIndex = 0; positionIndex < playerPositions.Length; positionIndex++) { Vector2 playerPosition = playerPositions[positionIndex]; Assert.That(player.TryReposition(playerPosition), Is.True); yield return new WaitForFixedUpdate(); playerHealth.GrantInvulnerability(10f); Vector2 inward = new Vector2( Mathf.Sign(-playerPosition.x), Mathf.Sign(-playerPosition.y)); if (inward.sqrMagnitude <= 0.0001f) { inward = Vector2.up; } inward.Normalize(); EnemyController enemy = Object.Instantiate( prefab, playerPosition + inward * 3f, Quaternion.identity); enemy.ConfigureRunEventEnemy( roles[kindIndex], CreateEdgeEventTuning( kinds[kindIndex], scaleMultipliers[kindIndex])); yield return null; float warningDeadline = Time.realtimeSinceStartup + 5f; while (enemy.State != EnemyState.Warning && enemy.State != EnemyState.Active && Time.realtimeSinceStartup < warningDeadline) { yield return new WaitForFixedUpdate(); } Assert.That( enemy.State == EnemyState.Warning || enemy.State == EnemyState.Active, Is.True, $"{kinds[kindIndex]} stalled before attack at position " + $"{playerPosition}; enemy={enemy.GroundAnchorPosition}, " + $"range={enemy.AttackRange}."); float activeDeadline = Time.realtimeSinceStartup + 2f; while (enemy.State != EnemyState.Active && Time.realtimeSinceStartup < activeDeadline) { yield return new WaitForFixedUpdate(); } Assert.That( enemy.State, Is.EqualTo(EnemyState.Active), $"{kinds[kindIndex]} did not reach its active hit window " + $"at position {playerPosition}."); Assert.That( Vector2.Distance(playerBody.position, playerPosition), Is.LessThan(0.02f), "The stationary edge target moved before the melee hit window."); EnemyAttack attack = enemy.GetComponent(); Assert.That(attack, Is.Not.Null); Assert.That(attack.IsContactWindowActive(0f), Is.True); Object.Destroy(enemy.gameObject); yield return null; } } } [UnityTest] public IEnumerator SpawnSampling_StaysReachableSafeAndOutsideVisibleView() { yield return LoadAndStartRun(); SpawnDirector director = Object.FindAnyObjectByType(); director.enabled = false; PlayerController player = Object.FindAnyObjectByType(); ArenaBounds bounds = Object.FindAnyObjectByType(); Camera camera = Camera.main; Assert.That(bounds, Is.Not.Null); Assert.That(camera, Is.Not.Null); camera.aspect = 16f / 9f; Vector2 halfExtents = bounds.SharedActorClampHalfExtents; Vector2[] corners = { Vector2.zero, new Vector2(-halfExtents.x, halfExtents.y), new Vector2(halfExtents.x, halfExtents.y), new Vector2(halfExtents.x, -halfExtents.y), new Vector2(-halfExtents.x, -halfExtents.y), }; for (int cornerIndex = 0; cornerIndex < corners.Length; cornerIndex++) { Assert.That(player.TryReposition(corners[cornerIndex]), Is.True); yield return new WaitForSecondsRealtime(0.35f); Bounds expandedVisible = bounds.GetCameraVisibleBounds(camera); expandedVisible.Expand(1f); for (int sample = 0; sample < 16; sample++) { Vector2 position = director.GetSpawnPositionForTests(); Assert.That(bounds.IsInsideReachableArena(position, 0.25f), Is.True); Assert.That( Vector2.Distance(position, corners[cornerIndex]), Is.GreaterThanOrEqualTo(4f)); bool insideExpandedVisible = position.x >= expandedVisible.min.x && position.x <= expandedVisible.max.x && position.y >= expandedVisible.min.y && position.y <= expandedVisible.max.y; Assert.That(insideExpandedVisible, Is.False); } } } [UnityTest] public IEnumerator RoomScaleComparison_UsesMatchedCameraAndMinimapForOriginalAndExpanded() { const int referenceSeed = 20260922; RunConstants constants = GameplayConstants.Current.Run; RoomScaleComparisonMode previousMode = constants.Stage1FirstCombatRoomScaleComparison; UnityEngine.Random.State previousRandomState = UnityEngine.Random.state; float previousTimeScale = Time.timeScale; bool previousProductionMode = RunManager.ForceProductionModeForTests; string captureDirectory = System.Environment.GetEnvironmentVariable( "BUMPCOMBAT_COMPARISON_CAPTURE_DIRECTORY"); try { constants.Stage1FirstCombatRoomScaleComparison = RoomScaleComparisonMode.Original; yield return LoadAndStartRun(); StageManager stageManager = Object.FindAnyObjectByType(); Camera camera = Camera.main; Assert.That(stageManager, Is.Not.Null); Assert.That(camera, Is.Not.Null); RoomScaleComparisonMode[] modes = { RoomScaleComparisonMode.Original, RoomScaleComparisonMode.Expanded, }; for (int modeIndex = 0; modeIndex < modes.Length; modeIndex++) { RoomScaleComparisonMode mode = modes[modeIndex]; constants.Stage1FirstCombatRoomScaleComparison = mode; stageManager.StartRun(referenceSeed); yield return new WaitForSecondsRealtime(0.4f); StageNode firstCombat = FindFirstCombat(stageManager.CurrentPlan); Assert.That(firstCombat, Is.Not.Null); Assert.That(firstCombat.PresetId, Is.EqualTo(mode == RoomScaleComparisonMode.Expanded ? LevelPresetCatalog.S1AComparisonExpandedPresetId : "GRAVE_S1_COMBAT_A")); StageDirection entryDirection = GetEntryDirection( stageManager.CurrentPlan.StartNode, firstCombat); // Reset immediately before the same room activation so the // encounter's UnityEngine.Random group rolls match A/B. UnityEngine.Random.InitState(referenceSeed); stageManager.RequestZoneTransition(firstCombat.Id, entryDirection); float deadline = Time.realtimeSinceStartup + 4f; while (stageManager.CurrentZone == null || stageManager.CurrentZone.NodeId != firstCombat.Id || stageManager.IsTransitioning) { Assert.That(Time.realtimeSinceStartup, Is.LessThan(deadline), "The comparison fixture did not reach the first combat room."); yield return null; } // CoTransitionZone sets CurrentZone before its doorway // hold. Wait for ActivateZone and encounter spawn to // finish before inspecting or capturing the room. yield return new WaitForSecondsRealtime(0.4f); ZoneInstance zone = stageManager.CurrentZone; Assert.That(stageManager.IsTransitioning, Is.False); AssertReferenceEncounter( zone, mode == RoomScaleComparisonMode.Expanded ? 6 : 3, mode == RoomScaleComparisonMode.Expanded ? 4 : 2); float expectedOrtho = mode == RoomScaleComparisonMode.Expanded ? constants.StageComparisonCameraViewHalfExtents.y : constants.StageComparisonOriginalCameraViewHalfExtents.y; Assert.That(zone.Preset.GridWidth, Is.EqualTo(mode == RoomScaleComparisonMode.Expanded ? 36 : 18)); Assert.That(zone.Preset.GridHeight, Is.EqualTo(mode == RoomScaleComparisonMode.Expanded ? 24 : 12)); PlayerController comparisonPlayer = Object.FindAnyObjectByType(); Vector2 returnPosition = comparisonPlayer.transform.position; Vector2 explorationPosition = returnPosition; bool foundUnseenCell = false; for (int y = 0; y < zone.Preset.GridHeight && !foundUnseenCell; y++) { for (int x = 0; x < zone.Preset.GridWidth; x++) { if (zone.GetRuntimeCell(x, y) == PresetCellType.Empty || zone.IsCellVisible(x, y)) { continue; } explorationPosition = zone.Preset.CellToWorldPosition( zone.ZoneCenter, new Vector2Int(x, y)); foundUnseenCell = true; break; } } Assert.That(foundUnseenCell, Is.True, "The comparison room must have an unseen terrain cell."); Assert.That(comparisonPlayer.TryReposition(explorationPosition), Is.True); zone.UpdateVisibility(explorationPosition); yield return null; Assert.That(comparisonPlayer.TryReposition(returnPosition), Is.True); zone.UpdateVisibility(returnPosition); yield return null; camera.Render(); Assert.That(camera.orthographicSize, Is.EqualTo(expectedOrtho).Within(0.01f)); AssertPixelPerfectReference( camera, mode == RoomScaleComparisonMode.Expanded ? 480 : 320, mode == RoomScaleComparisonMode.Expanded ? 270 : 180); AssertMinimapTerrainMatches(zone); if (!string.IsNullOrWhiteSpace(captureDirectory)) { CaptureWorldOnlyCamera( camera, captureDirectory, mode == RoomScaleComparisonMode.Expanded ? "expanded" : "original"); } } } finally { constants.Stage1FirstCombatRoomScaleComparison = previousMode; UnityEngine.Random.state = previousRandomState; Time.timeScale = previousTimeScale; RunManager.ForceProductionModeForTests = previousProductionMode; } } private static IEnumerator DestroyActiveEnemies() { EnemyController[] enemies = Object.FindObjectsByType( FindObjectsSortMode.None); for (int i = 0; i < enemies.Length; i++) { if (enemies[i] != null) { Object.Destroy(enemies[i].gameObject); } } yield return null; } private static StageNode FindFirstCombat(StagePlan plan) { StageNode entrance = plan.StartNode; for (int i = 0; i < plan.Nodes.Count; i++) { StageNode node = plan.Nodes[i]; if (node.NodeType != StageNodeType.Combat) { continue; } for (int direction = 0; direction < 4; direction++) { if (entrance.GetConnection((StageDirection)direction) == node.Id) { return node; } } } return null; } private static StageDirection GetEntryDirection(StageNode entrance, StageNode target) { for (int direction = 0; direction < 4; direction++) { StageDirection sourceDirection = (StageDirection)direction; if (entrance.GetConnection(sourceDirection) == target.Id) { return sourceDirection.Opposite(); } } return StageDirection.West; } private static void AssertPixelPerfectReference( Camera camera, int expectedWidth, int expectedHeight) { Component pixelPerfect = camera.GetComponent( "UnityEngine.Rendering.Universal.PixelPerfectCamera"); Assert.That(pixelPerfect, Is.Not.Null); PropertyInfo referenceWidth = pixelPerfect.GetType().GetProperty("refResolutionX"); PropertyInfo referenceHeight = pixelPerfect.GetType().GetProperty("refResolutionY"); Assert.That(referenceWidth.GetValue(pixelPerfect), Is.EqualTo(expectedWidth)); Assert.That(referenceHeight.GetValue(pixelPerfect), Is.EqualTo(expectedHeight)); } private static void AssertMinimapTerrainMatches(ZoneInstance zone) { RunHUD hud = Object.FindAnyObjectByType(); Assert.That(hud, Is.Not.Null); FieldInfo terrainField = typeof(RunHUD).GetField( "minimapTerrainRoot", BindingFlags.Instance | BindingFlags.NonPublic); RectTransform terrainRoot = terrainField?.GetValue(hud) as RectTransform; Assert.That(terrainRoot, Is.Not.Null); int visibleCellCount = 0; for (int y = 0; y < zone.Preset.GridHeight; y++) { for (int x = 0; x < zone.Preset.GridWidth; x++) { if (zone.GetRuntimeCell(x, y) != PresetCellType.Empty) { visibleCellCount++; } } } Assert.That(terrainRoot.childCount, Is.EqualTo(visibleCellCount)); Assert.That(zone.VisibilityRevision, Is.GreaterThan(0)); int darkCellCount = 0; int dimCellCount = 0; for (int y = 0; y < zone.Preset.GridHeight; y++) { for (int x = 0; x < zone.Preset.GridWidth; x++) { if (zone.GetRuntimeCell(x, y) == PresetCellType.Empty) { continue; } Image cell = terrainRoot.Find($"Terrain {x},{y}")?.GetComponent(); Assert.That(cell, Is.Not.Null); Color32 color = cell.color; if (zone.IsCellVisible(x, y)) { Assert.That(color, Is.Not.EqualTo(new Color32(2, 4, 5, 255))); } else if (zone.IsCellExplored(x, y)) { dimCellCount++; Assert.That(color.a, Is.EqualTo(255)); } else { darkCellCount++; Assert.That(color, Is.EqualTo(new Color32(2, 4, 5, 255))); } } } Assert.That(dimCellCount, Is.GreaterThan(0)); Assert.That(darkCellCount, Is.GreaterThan(0)); } private static void AssertReferenceEncounter( ZoneInstance zone, int expectedBatCount, int expectedSkeletonCount) { int batCount = 0; int skeletonCount = 0; for (int i = 0; i < zone.SpawnedEnemies.Count; i++) { EnemyController enemy = zone.SpawnedEnemies[i]; if (enemy == null) { continue; } if (enemy.name.StartsWith("Bat", System.StringComparison.Ordinal)) { batCount++; } else if (enemy.name.StartsWith("Skeleton", System.StringComparison.Ordinal)) { skeletonCount++; } } Assert.That(batCount, Is.EqualTo(expectedBatCount)); Assert.That(skeletonCount, Is.EqualTo(expectedSkeletonCount)); } private static void CaptureWorldOnlyCamera( Camera camera, string directory, string variant) { System.IO.Directory.CreateDirectory(directory); RenderTexture previousTarget = camera.targetTexture; RenderTexture previousActive = RenderTexture.active; RenderTexture target = new(640, 360, 24, RenderTextureFormat.ARGB32); Texture2D snapshot = null; try { target.Create(); camera.targetTexture = target; camera.Render(); RenderTexture.active = target; snapshot = new Texture2D(640, 360, TextureFormat.RGB24, false); snapshot.ReadPixels(new Rect(0f, 0f, 640f, 360f), 0, 0); snapshot.Apply(); string path = System.IO.Path.Combine( directory, $"s1a-{variant}-world.png"); System.IO.File.WriteAllBytes(path, snapshot.EncodeToPNG()); } finally { camera.targetTexture = previousTarget; RenderTexture.active = previousActive; if (snapshot != null) { Object.Destroy(snapshot); } target.Release(); Object.Destroy(target); } } private static EnemyController FindRangedPrefab(SpawnDirector director) { FieldInfo field = typeof(SpawnDirector).GetField( "enemyPrefabs", BindingFlags.Instance | BindingFlags.NonPublic); EnemyController[] prefabs = field?.GetValue(director) as EnemyController[]; if (prefabs == null) { return null; } for (int i = 0; i < prefabs.Length; i++) { if (prefabs[i] != null && prefabs[i].Definition != null && prefabs[i].Definition.IsRanged) { return prefabs[i]; } } return null; } private static EnemyController FindCatalogPrefab( SpawnDirector director, EnemyKind kind) { string[] fieldNames = { "enemyPrefabs", "elitePrefabs", "midBossPrefabs", "finalBossPrefabs", }; for (int fieldIndex = 0; fieldIndex < fieldNames.Length; fieldIndex++) { FieldInfo field = typeof(SpawnDirector).GetField( fieldNames[fieldIndex], BindingFlags.Instance | BindingFlags.NonPublic); EnemyController[] prefabs = field?.GetValue(director) as EnemyController[]; if (prefabs == null) { continue; } for (int prefabIndex = 0; prefabIndex < prefabs.Length; prefabIndex++) { EnemyController prefab = prefabs[prefabIndex]; if (prefab != null && prefab.Definition != null && prefab.Definition.Kind == kind) { return prefab; } } } return null; } private static RunEventEnemyTuning CreateEdgeEventTuning( EnemyKind kind, float scale) { return RunEventEnemyTuning.Create( kind, 1f, 0, scale, Color.white, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1, 1f, 1f, 1, 1f, false); } private static IEnumerator LoadAndStartRun() { ArtifactRewardController.GrantCatalogForTests = false; AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; RunManager runManager = RunManager.Instance; if (runManager.IsTitleScreen) { Assert.That(runManager.BeginRun(), Is.True); } ArtifactRewardController reward = Object.FindAnyObjectByType(); Assert.That(reward, Is.Not.Null); for (int selection = 0; selection < 3; selection++) { float deadline = Time.realtimeSinceStartup + 2f; while (!reward.DebugIsSelectionVisible && Time.realtimeSinceStartup < deadline) { yield return null; } Assert.That(reward.DebugIsSelectionVisible, Is.True); Assert.That(reward.DebugChooseOption(0), Is.True); yield return null; } // The first artifact acquisition queues the tutorial modal. This // helper is used by gameplay tests, so acknowledge any queued // guidance before waiting on gameplay-time coroutines. RunTutorialController tutorials = Object.FindAnyObjectByType(); for (int tutorial = 0; tutorials != null && tutorials.DebugIsVisible && tutorial < 4; tutorial++) { Assert.That(tutorials.DebugContinue(), Is.True); yield return null; } Assert.That( RunManager.GameplayInputEnabled, Is.True, "Finite arena tests require startup tutorial guidance to be closed."); } } }