using System.Collections.Generic; using BumpCombat.Core; using BumpCombat.Stage; using NUnit.Framework; using UnityEngine; namespace BumpCombat.Tests { public sealed class LevelPresetTests { [Test] public void LevelPresetCatalog_ContainsAll12Presets() { LevelPresetCatalog.EnsureInitialized(); var all = LevelPresetCatalog.AllPresets; string[] expectedPresets = new string[] { "GRAVE_S1_COMBAT_A", "GRAVE_S1_COMBAT_B", "GRAVE_S1_FORK", "GRAVE_S1_ELITE_ARENA", "GRAVE_S2_COMBAT_A", "GRAVE_S2_COMBAT_B", "GRAVE_S2_FORK_HAZARD", "GRAVE_S2_MIDBOSS", "GRAVE_S3_COMBAT_A", "GRAVE_S3_COMBAT_B", "GRAVE_S3_PREBOSS_FORK", "GRAVE_S3_FINAL_NECROMANCER", }; foreach (string presetId in expectedPresets) { RoomPreset preset = LevelPresetCatalog.GetPreset(presetId); Assert.IsNotNull(preset, $"Preset {presetId} should be registered in LevelPresetCatalog."); Assert.AreEqual(presetId, preset.PresetId); } } [Test] public void LevelPresets_MatchSpecificationDimensions() { // Stage 1: 18x12 (Elite 20x14) AssertDimensions("GRAVE_S1_COMBAT_A", 18, 12); AssertDimensions("GRAVE_S1_COMBAT_B", 18, 12); AssertDimensions("GRAVE_S1_FORK", 18, 12); AssertDimensions("GRAVE_S1_ELITE_ARENA", 20, 14); // Stage 2: 20x14 (MidBoss 22x16) AssertDimensions("GRAVE_S2_COMBAT_A", 20, 14); AssertDimensions("GRAVE_S2_COMBAT_B", 20, 14); AssertDimensions("GRAVE_S2_FORK_HAZARD", 20, 14); AssertDimensions("GRAVE_S2_MIDBOSS", 22, 16); // Stage 3: 22x16 (PreBoss 20x14, FinalBoss 24x18) AssertDimensions("GRAVE_S3_COMBAT_A", 22, 16); AssertDimensions("GRAVE_S3_COMBAT_B", 22, 16); AssertDimensions("GRAVE_S3_PREBOSS_FORK", 20, 14); AssertDimensions("GRAVE_S3_FINAL_NECROMANCER", 24, 18); } [Test] public void S1AExpandedComparison_UsesActualGridAndPreservesCombatContract() { RoomPreset original = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A"); RoomPreset expanded = LevelPresetCatalog.GetPreset( LevelPresetCatalog.S1AComparisonExpandedPresetId); Assert.That(original, Is.Not.Null); Assert.That(expanded, Is.Not.Null); Assert.That(original.GridWidth, Is.EqualTo(18)); Assert.That(original.GridHeight, Is.EqualTo(12)); Assert.That(expanded.GridWidth, Is.EqualTo(36)); Assert.That(expanded.GridHeight, Is.EqualTo(24)); List expectedObstacles = new() { new(10, 7), new(10, 8), new(11, 8), new(24, 7), new(20, 14), new(21, 14), new(13, 16), new(14, 16), }; List actualObstacles = new(); for (int y = 0; y < expanded.GridHeight; y++) { for (int x = 0; x < expanded.GridWidth; x++) { if (expanded.GridMap[x, y] == PresetCellType.Obstacle) { actualObstacles.Add(new Vector2Int(x, y)); } } } Assert.That(actualObstacles, Is.EquivalentTo(expectedObstacles)); Assert.That(actualObstacles.Count, Is.EqualTo(8)); Assert.That(expanded.SpawnPoints.Count, Is.EqualTo(original.SpawnPoints.Count)); Assert.That(expanded.PlayerAnchor, Is.EqualTo(new Vector2Int(6, 11))); foreach (RoomPresetSocket socket in expanded.Sockets) { Assert.That(socket.Width, Is.EqualTo(1), $"{socket.Id} must remain a narrow one-cell door."); } Assert.That(HasCellPath(expanded, new Vector2Int(2, 11), new Vector2Int(33, 11)), Is.True, "Expanded S1-A must preserve a clear central approach."); Assert.That(HasCellPath(expanded, new Vector2Int(2, 11), new Vector2Int(8, 6)), Is.True, "Expanded S1-A must preserve a reachable left-flank approach."); Assert.That(HasCellPath(expanded, new Vector2Int(33, 11), new Vector2Int(27, 17)), Is.True, "Expanded S1-A must preserve a reachable right-flank approach."); AssertExpandedEncounterPoolsMatch(original, expanded); } [Test] public void StageFog_ExplorationPersistsOnRevisit_AndResetsForNewZone() { RoomPreset preset = LevelPresetCatalog.GetPreset( LevelPresetCatalog.S1AComparisonExpandedPresetId); StageNode node = new() { Id = 701, NodeType = StageNodeType.Combat, PresetId = preset.PresetId, }; node.SetConnection(StageDirection.North, 1); node.SetConnection(StageDirection.South, 2); node.SetConnection(StageDirection.West, 3); node.SetConnection(StageDirection.East, 4); GameObject firstObject = new("FogZone_First"); GameObject secondObject = new("FogZone_Second"); GameObject partialObject = new("FogZone_PartialSockets"); try { ZoneInstance firstZone = firstObject.AddComponent(); firstZone.Initialize(node, preset.PlayableHalfExtents); Vector2 start = preset.CellToWorldPosition(Vector2.zero, new Vector2Int(6, 11)); Vector2 far = preset.CellToWorldPosition(Vector2.zero, new Vector2Int(30, 17)); Vector2 occlusionOrigin = preset.CellToWorldPosition( Vector2.zero, new Vector2Int(8, 11)); firstZone.UpdateVisibility(occlusionOrigin); Assert.That(firstZone.IsCellVisible(12, 7), Is.False, "A cover cell must block diagonal terrain LOS behind it."); firstZone.UpdateVisibility(start); Assert.That(firstZone.IsCellVisible(6, 11), Is.True); Assert.That(firstZone.IsCellExplored(30, 17), Is.False); firstZone.UpdateVisibility(far); Assert.That(firstZone.IsCellVisible(30, 17), Is.True); Assert.That(firstZone.IsCellExplored(6, 11), Is.True); firstZone.UpdateVisibility(start); Assert.That(firstZone.IsCellExplored(30, 17), Is.True, "A revisited room must retain explored terrain."); Assert.That(firstZone.IsCellVisible(30, 17), Is.False, "Explored terrain must become hidden again outside current LOS."); ZoneInstance secondZone = secondObject.AddComponent(); secondZone.Initialize(node, preset.PlayableHalfExtents); secondZone.UpdateVisibility(start); Assert.That(secondZone.IsCellExplored(30, 17), Is.False, "A newly created run zone must not inherit exploration memory."); StageNode partialNode = new() { Id = 702, NodeType = StageNodeType.Combat, PresetId = preset.PresetId, }; partialNode.SetConnection(StageDirection.North, 1); ZoneInstance partialZone = partialObject.AddComponent(); partialZone.Initialize(partialNode, preset.PlayableHalfExtents); Assert.That(partialZone.GetRuntimeCell(17, 0), Is.EqualTo(PresetCellType.Socket), "The active socket corridor must remain part of the runtime grid."); Assert.That(partialZone.GetRuntimeCell(0, 11), Is.EqualTo(PresetCellType.Empty), "An inactive socket must be opaque in the runtime grid."); StageFogOfWar partialFog = partialObject.AddComponent(); partialFog.Initialize(partialZone); Assert.That(partialFog.GetCellFogAlpha(0, 11), Is.EqualTo(0f)); MeshFilter fogMeshFilter = partialObject.GetComponent(); Assert.That(fogMeshFilter, Is.Not.Null); Color[] fogColors = fogMeshFilter.sharedMesh.colors; int emptyVertex = 11 * preset.GridWidth * 4; for (int vertexOffset = 0; vertexOffset < 4; vertexOffset++) { Color emptyColor = fogColors[emptyVertex + vertexOffset]; Assert.That(emptyColor.r, Is.EqualTo(0f).Within(0.0001f)); Assert.That(emptyColor.g, Is.EqualTo(0f).Within(0.0001f)); Assert.That(emptyColor.b, Is.EqualTo(0f).Within(0.0001f)); Assert.That(emptyColor.a, Is.EqualTo(0f).Within(0.0001f)); } } finally { Object.DestroyImmediate(firstObject); Object.DestroyImmediate(secondObject); Object.DestroyImmediate(partialObject); } } private static void AssertExpandedEncounterPoolsMatch(RoomPreset expected, RoomPreset actual) { Assert.That(actual.EncounterPool.Count, Is.EqualTo(expected.EncounterPool.Count)); for (int encounterIndex = 0; encounterIndex < expected.EncounterPool.Count; encounterIndex++) { RoomEncounter expectedEncounter = expected.EncounterPool[encounterIndex]; RoomEncounter actualEncounter = actual.EncounterPool[encounterIndex]; Assert.That(actualEncounter.Name, Is.EqualTo(expectedEncounter.Name)); Assert.That(actualEncounter.Items.Count, Is.EqualTo(expectedEncounter.Items.Count)); for (int itemIndex = 0; itemIndex < expectedEncounter.Items.Count; itemIndex++) { Assert.That(actualEncounter.Items[itemIndex].EnemyKind, Is.EqualTo(expectedEncounter.Items[itemIndex].EnemyKind)); Assert.That(actualEncounter.Items[itemIndex].Count, Is.EqualTo(expectedEncounter.Items[itemIndex].Count * 2)); } } } private static bool HasCellPath(RoomPreset preset, Vector2Int start, Vector2Int target) { if (!RoomPathfinder.IsWalkable(preset, start.x, start.y) || !RoomPathfinder.IsWalkable(preset, target.x, target.y)) { return false; } Queue pending = new(); HashSet visited = new() { start }; pending.Enqueue(start); Vector2Int[] directions = { Vector2Int.up, Vector2Int.right, Vector2Int.down, Vector2Int.left, }; while (pending.Count > 0) { Vector2Int current = pending.Dequeue(); if (current == target) { return true; } for (int i = 0; i < directions.Length; i++) { Vector2Int next = current + directions[i]; if (visited.Contains(next) || !RoomPathfinder.IsWalkable(preset, next.x, next.y)) { continue; } visited.Add(next); pending.Enqueue(next); } } return false; } private void AssertDimensions(string presetId, int expectedW, int expectedH) { RoomPreset preset = LevelPresetCatalog.GetPreset(presetId); Assert.IsNotNull(preset, $"Preset {presetId} must exist."); Assert.AreEqual(expectedW, preset.GridWidth, $"{presetId} GridWidth mismatch"); Assert.AreEqual(expectedH, preset.GridHeight, $"{presetId} GridHeight mismatch"); Assert.AreEqual(new Vector2(expectedW * 0.5f, expectedH * 0.5f), preset.PlayableHalfExtents); } [Test] public void LevelPresets_HaveObstaclesAndSpawnPoints() { string[] combatAndBossPresets = new string[] { "GRAVE_S1_COMBAT_A", "GRAVE_S1_COMBAT_B", "GRAVE_S1_FORK", "GRAVE_S1_ELITE_ARENA", "GRAVE_S2_COMBAT_A", "GRAVE_S2_COMBAT_B", "GRAVE_S2_FORK_HAZARD", "GRAVE_S2_MIDBOSS", "GRAVE_S3_COMBAT_A", "GRAVE_S3_COMBAT_B", "GRAVE_S3_PREBOSS_FORK", "GRAVE_S3_FINAL_NECROMANCER", }; foreach (string presetId in combatAndBossPresets) { RoomPreset preset = LevelPresetCatalog.GetPreset(presetId); Assert.IsNotNull(preset); // Check obstacles exist int obstacleCount = 0; for (int y = 0; y < preset.GridHeight; y++) { for (int x = 0; x < preset.GridWidth; x++) { if (preset.GridMap[x, y] == PresetCellType.Obstacle) obstacleCount++; } } Assert.Greater(obstacleCount, 0, $"{presetId} must have at least one obstacle."); // Check sockets exist Assert.Greater(preset.Sockets.Count, 0, $"{presetId} must have connection sockets."); // Check spawn points exist Assert.Greater(preset.SpawnPoints.Count, 0, $"{presetId} must have spawn points."); } } [Test] public void StageGenerator_AssignsValidLevelPresetsToAllNodes() { for (int stage = 1; stage <= 3; stage++) { StagePlan plan = StageGenerator.GenerateStage(stage, 424242); Assert.IsNotNull(plan); Assert.Greater(plan.Nodes.Count, 0); foreach (StageNode node in plan.Nodes) { Assert.IsFalse(string.IsNullOrEmpty(node.PresetId), $"Node {node.Id} ({node.NodeType}) should have a PresetId."); RoomPreset preset = LevelPresetCatalog.GetPreset(node.PresetId); Assert.IsNotNull(preset, $"Preset {node.PresetId} assigned to Node {node.Id} ({node.NodeType}) must exist in catalog."); } } } [Test] public void RoomPreset_CoordinateConversionIsSymmetricAndAccurate() { RoomPreset preset = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A"); // 18x12 Assert.IsNotNull(preset); Vector2 leftTop = preset.CellToLocalPosition(0, 0); Vector2 rightBottom = preset.CellToLocalPosition(17, 11); Assert.AreEqual(-8.5f, leftTop.x, 0.001f); Assert.AreEqual(5.5f, leftTop.y, 0.001f); Assert.AreEqual(8.5f, rightBottom.x, 0.001f); Assert.AreEqual(-5.5f, rightBottom.y, 0.001f); // Origin symmetry Assert.AreEqual(-leftTop.x, rightBottom.x, 0.001f); Assert.AreEqual(-leftTop.y, rightBottom.y, 0.001f); } [Test] public void RoomPreset_EntryPositionGuaranteesSafetyOffset() { RoomPreset preset = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A"); // 18x12 Assert.IsNotNull(preset); Vector2 entryWest = preset.GetEntryLocalPosition(StageDirection.West); Vector2 socketWest = preset.GetSocketLocalPosition(StageDirection.West); // Entry should be at least two cells inward so a transition cannot immediately retrigger. Assert.Greater(entryWest.x, socketWest.x); Assert.GreaterOrEqual(entryWest.x - socketWest.x, 2f); } [Test] public void AllPresets_AllSockets_HaveClearPathAndNoObstacleOverlapAtEntry() { float playerRadius = 0.275f; List errors = new(); foreach (var kvp in LevelPresetCatalog.AllPresets) { RoomPreset preset = kvp.Value; foreach (var socket in preset.Sockets) { Vector2 entryPos = preset.GetEntryLocalPosition(socket); for (int y = 0; y < preset.GridHeight; y++) { for (int x = 0; x < preset.GridWidth; x++) { if (!RoomPathfinder.IsWalkable(preset, x, y)) { Vector2 obsCenter = preset.CellToLocalPosition(x, y); float dx = Mathf.Max(0f, Mathf.Abs(entryPos.x - obsCenter.x) - 0.5f); float dy = Mathf.Max(0f, Mathf.Abs(entryPos.y - obsCenter.y) - 0.5f); float dist = Mathf.Sqrt(dx * dx + dy * dy); if (dist <= playerRadius) { errors.Add($"Preset '{preset.PresetId}' socket '{socket.Id}' ({socket.Direction}) entry {entryPos} overlaps blocked cell at ({x}, {y}) (dist={dist:F3}, radius={playerRadius})"); } } } } } } Assert.IsEmpty(errors, string.Join("\n", errors)); } [Test] public void AllPresets_SocketCellsMatchConfiguredWidths_AndCatalogUsesMixedWidths() { bool hasOneTileSocket = false; bool hasWiderSocket = false; foreach (var kvp in LevelPresetCatalog.AllPresets) { RoomPreset preset = kvp.Value; HashSet expectedSocketCells = new(); foreach (RoomPresetSocket socket in preset.Sockets) { Assert.GreaterOrEqual(socket.Width, 1, $"{preset.PresetId}/{socket.Id} width must be at least 1."); hasOneTileSocket |= socket.Width == 1; hasWiderSocket |= socket.Width > 1; bool horizontal = socket.Direction == StageDirection.North || socket.Direction == StageDirection.South; for (int i = 0; i < socket.Width; i++) { expectedSocketCells.Add(new Vector2Int( socket.CellPosition.x + (horizontal ? i : 0), socket.CellPosition.y + (horizontal ? 0 : i))); } } HashSet actualSocketCells = new(); for (int y = 0; y < preset.GridHeight; y++) { for (int x = 0; x < preset.GridWidth; x++) { if (preset.GridMap[x, y] == PresetCellType.Socket) { actualSocketCells.Add(new Vector2Int(x, y)); } } } CollectionAssert.AreEquivalent( expectedSocketCells, actualSocketCells, $"{preset.PresetId} socket cells must exactly match declared socket widths."); } Assert.IsTrue(hasOneTileSocket, "Catalog must contain at least one 1-tile doorway."); Assert.IsTrue(hasWiderSocket, "Catalog must retain wider doorways for rooms that need them."); } [TestCase("GRAVE_S1_FORK")] [TestCase("GRAVE_S2_FORK_HAZARD")] [TestCase("GRAVE_S3_PREBOSS_FORK")] public void ForkPresets_DoNotUseSolidTwoByTwoObstacleBlocks(string presetId) { RoomPreset preset = LevelPresetCatalog.GetPreset(presetId); Assert.IsNotNull(preset); for (int y = 0; y < preset.GridHeight - 1; y++) { for (int x = 0; x < preset.GridWidth - 1; x++) { bool solidBlock = preset.GridMap[x, y] == PresetCellType.Obstacle && preset.GridMap[x + 1, y] == PresetCellType.Obstacle && preset.GridMap[x, y + 1] == PresetCellType.Obstacle && preset.GridMap[x + 1, y + 1] == PresetCellType.Obstacle; Assert.IsFalse(solidBlock, $"{presetId} must use broken LOS cover instead of a solid 2x2 obstacle block at ({x}, {y})."); } } } [Test] public void Player_ColliderBottom_AlignsWithGroundAnchor_NoInvisibleAirGapAboveObstacles() { #if UNITY_EDITOR GameObject playerPrefab = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/_Project/Prefabs/Player/Player.prefab"); Assert.IsNotNull(playerPrefab); CircleCollider2D col = playerPrefab.GetComponent(); Assert.IsNotNull(col); // Feet anchor in Swordsman sprite is around -0.28 (local units) float expectedFeetLocalY = -0.28f; float colliderBottomLocalY = col.offset.y - col.radius; // Collider bottom must align with the feet anchor within 0.02 units, // NOT hang 0.22 units below the feet creating an invisible 20px barrier Assert.AreEqual(expectedFeetLocalY, colliderBottomLocalY, 0.02f, $"Player collider bottom ({colliderBottomLocalY:F3}) must align with feet ground anchor ({expectedFeetLocalY:F3}) so approaching obstacles from above does not leave an invisible air gap."); #endif } [Test] public void L01_InvalidPresetData_ThrowsValidationException() { // Case 1: Row length mismatch (17 chars instead of 18) string[] shortRowGrid = new string[] { "□□□□□□□○○□□□□□□□□□", "□□□■■■■■■■■■■□□□□", // 17 chars! "□□■■◇■■■■■■◇■■□□□□", "□□■■■■▲▲■■■■■■□□□□", "○■■■■■▲▲■■■■■■■■■○", "○■■☆■■■■■■■■◇■■■■○", "□■■■■■■■■■■■■■■■■□", "□■■■■▲▲■■■■■■■■■■□", "□■■◇■▲▲■■■■◇■■■■■□", "□□■■■■■■■■■■■■□□□□", "□□□□□■■■■■■□□□□□□□", "□□□□□□□○○□□□□□□□□□", }; RoomPreset p1 = new RoomPreset { PresetId = "TEST_SHORT_ROW" }; var ex1 = Assert.Throws(() => LevelPresetCatalog.ParseGridAndAnchors(p1, shortRowGrid, 18, 12)); StringAssert.Contains("length mismatch", ex1.Message); // Case 2: Row length mismatch (19 chars instead of 18) string[] longRowGrid = new string[] { "□□□□□□□○○□□□□□□□□□", "□□□■■■■■■■■■■□□□□□", "□□■■◇■■■■■■◇■■□□□□", "□□■■■■▲▲■■■■■■□□□□", "○■■■■■▲▲■■■■■■■■■○", "○■■☆■■■■■■■■◇■■■■○", "□■■■■■■■■■■■■■■■■□", "□■■■■▲▲■■■■■■■■■■□", "□■■◇■▲▲■■■■◇■■■■■□", "□□■■■■■■■■■■■■□□□□", "□□□□□■■■■■■□□□□□□□□", // 19 chars! "□□□□□□□○○□□□□□□□□□", }; RoomPreset p2 = new RoomPreset { PresetId = "TEST_LONG_ROW" }; var ex2 = Assert.Throws(() => LevelPresetCatalog.ParseGridAndAnchors(p2, longRowGrid, 18, 12)); StringAssert.Contains("length mismatch", ex2.Message); // Case 3: Unknown character string[] unknownCharGrid = new string[] { "□□□□□□□○○□□□□□□□□□", "□□□■■■■■■■■■■□□□□□", "□□■■◇■■■■■■◇■■□□□□", "□□■■■■▲▲■■■■■■□□□□", "○■■■■■▲▲■■■■■■■■■○", "○■■☆■■■■■■■■◇■■■■○", "□■■■■■■■■■■■■■■■■□", "□■■■■▲▲■■■■■■■■■■□", "□■■◇■▲▲■■■■◇■■■■■□", "□□■■■■■■■■■■■■□□□□", "□□□□□■■■■■■□□□□□□□", "□□□□□□□○○□□□□□□□□X", // 'X' is unknown! }; RoomPreset p3 = new RoomPreset { PresetId = "TEST_UNKNOWN_CHAR" }; var ex3 = Assert.Throws(() => LevelPresetCatalog.ParseGridAndAnchors(p3, unknownCharGrid, 18, 12)); StringAssert.Contains("Unknown cell character", ex3.Message); // Case 4: Spawn anchor inside obstacle string[] obstacleSpawnGrid = new string[] { "□□□□□□□○○□□□□□□□□□", "□□□■■■■■■■■■■□□□□□", "□□■■◇■■■■■■◇■■□□□□", "□□■■■■▲▲■■■■■■□□□□", "○■■■■■▲▲■■■■■■■■■○", "○■■☆■■■■■■■■◇■■■■○", "□■■■■■■■■■■■■■■■■□", "□■■■■▲▲■■■■■■■■■■□", "□■■◇■▲▲■■■■◇■■■■■□", "□□■■■■■■■■■■■■□□□□", "□□□□□■■■■■■□□□□□□□", "□□□□□□□○○□□□□□□□□□", }; RoomPreset p4 = new RoomPreset { PresetId = "TEST_OBSTACLE_SPAWN" }; p4.Sockets.Add(new RoomPresetSocket("N1", StageDirection.North, new Vector2Int(7, 0), 2)); p4.Sockets.Add(new RoomPresetSocket("W1", StageDirection.West, new Vector2Int(0, 4), 2)); p4.Sockets.Add(new RoomPresetSocket("E1", StageDirection.East, new Vector2Int(17, 4), 2)); p4.Sockets.Add(new RoomPresetSocket("S1", StageDirection.South, new Vector2Int(7, 11), 2)); // Spawn point at (6, 3) which is Obstacle! p4.SpawnPoints.Add(new RoomPresetSpawnPoint("M_BAD", SpawnRole.NormalMelee, new Vector2Int(6, 3))); var ex4 = Assert.Throws(() => LevelPresetCatalog.ParseGridAndAnchors(p4, obstacleSpawnGrid, 18, 12)); StringAssert.Contains("cannot be on Obstacle", ex4.Message); } [Test] public void S1A_NormalizedPreset_HasAccurateGeometrySocketsAndEncounterPool() { RoomPreset preset = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A"); Assert.IsNotNull(preset); Assert.AreEqual(18, preset.GridWidth); Assert.AreEqual(12, preset.GridHeight); // Sockets check Assert.AreEqual(4, preset.Sockets.Count); RoomPresetSocket? n1 = preset.GetSocket("N1"); RoomPresetSocket? w1 = preset.GetSocket("W1"); RoomPresetSocket? e1 = preset.GetSocket("E1"); RoomPresetSocket? s1 = preset.GetSocket("S1"); Assert.IsTrue(n1.HasValue && n1.Value.Direction == StageDirection.North && n1.Value.CellPosition == new Vector2Int(7, 0) && n1.Value.Width == 1); Assert.IsTrue(w1.HasValue && w1.Value.Direction == StageDirection.West && w1.Value.CellPosition == new Vector2Int(0, 4) && w1.Value.Width == 1); Assert.IsTrue(e1.HasValue && e1.Value.Direction == StageDirection.East && e1.Value.CellPosition == new Vector2Int(17, 4) && e1.Value.Width == 1); Assert.IsTrue(s1.HasValue && s1.Value.Direction == StageDirection.South && s1.Value.CellPosition == new Vector2Int(7, 11) && s1.Value.Width == 1); // Obstacle check: five approved cover cells. int obstacleCount = 0; for (int y = 0; y < 12; y++) { for (int x = 0; x < 18; x++) { if (preset.GridMap[x, y] == PresetCellType.Obstacle) obstacleCount++; } } Assert.AreEqual(5, obstacleCount, "S1-A must keep five obstacle cells at approved room coordinates"); // Approved cover positions. Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[6, 3]); Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[6, 4]); Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[12, 4]); Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[10, 6]); Assert.AreEqual(PresetCellType.Obstacle, preset.GridMap[10, 7]); // Spawn points check: M01~M05 Assert.AreEqual(5, preset.SpawnPoints.Count); Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M01" && p.Role == SpawnRole.NormalMelee && p.CellPosition == new Vector2Int(4, 3))); Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M02" && p.Role == SpawnRole.NormalMelee && p.CellPosition == new Vector2Int(10, 2))); Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M03" && p.Role == SpawnRole.Crowd && p.CellPosition == new Vector2Int(11, 5))); Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M04" && p.Role == SpawnRole.NormalMelee && p.CellPosition == new Vector2Int(4, 8))); Assert.IsTrue(preset.SpawnPoints.Exists(p => p.Id == "M05" && p.Role == SpawnRole.NormalMelee && p.CellPosition == new Vector2Int(12, 8))); // EncounterPool check Assert.AreEqual(3, preset.EncounterPool.Count); RoomEncounter encA = preset.EncounterPool.Find(e => e.Name == "Encounter A"); Assert.IsNotNull(encA); Assert.AreEqual(2, encA.Items.Count); Assert.AreEqual("Bat", encA.Items[0].EnemyKind); Assert.AreEqual(3, encA.Items[0].Count); Assert.AreEqual("Skeleton", encA.Items[1].EnemyKind); Assert.AreEqual(2, encA.Items[1].Count); } [Test] public void RoomPreset_SocketPhysicalCenter_MatchesConfiguredWidth() { RoomPreset preset = LevelPresetCatalog.GetPreset("GRAVE_S1_COMBAT_A"); Assert.IsNotNull(preset); // Width 1 sockets are centered on their declared cell. // W1 cell (0,4) => local (-8.5, 1.5). RoomPresetSocket w1 = preset.GetSocket("W1").Value; Vector2 w1Center = preset.GetSocketCenterLocalPosition(w1); Assert.AreEqual(-8.5f, w1Center.x, 0.001f); Assert.AreEqual(1.5f, w1Center.y, 0.001f); // E1 cell (17,4) => local (8.5, 1.5). RoomPresetSocket e1 = preset.GetSocket("E1").Value; Vector2 e1Center = preset.GetSocketCenterLocalPosition(e1); Assert.AreEqual(8.5f, e1Center.x, 0.001f); Assert.AreEqual(1.5f, e1Center.y, 0.001f); // N1 cell (7,0) => local (-1.5, 5.5). RoomPresetSocket n1 = preset.GetSocket("N1").Value; Vector2 n1Center = preset.GetSocketCenterLocalPosition(n1); Assert.AreEqual(-1.5f, n1Center.x, 0.001f); Assert.AreEqual(5.5f, n1Center.y, 0.001f); // S1 cell (7,11) => local (-1.5, -5.5). RoomPresetSocket s1 = preset.GetSocket("S1").Value; Vector2 s1Center = preset.GetSocketCenterLocalPosition(s1); Assert.AreEqual(-1.5f, s1Center.x, 0.001f); Assert.AreEqual(-5.5f, s1Center.y, 0.001f); } } }