using System.Collections.Generic; using System.Linq; using NUnit.Framework; using UnityEngine; using Vanguard.Battle; using Vanguard.Board3D; using Vanguard.Shared.Protocol; namespace Vanguard.Tests { public sealed class GameBoard3DTests { [Test] public void Board_Shows_Attack_Arrow_For_Active_Battle() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); var view = BuildView(you: 0); view.Battle = new BattleView { AttackerPlayer = 0, AttackerSlot = 3, TargetSlot = -1, GuardShield = 5000, }; board.Render(view, new BoardRenderState()); Assert.IsTrue(board.AttackArrowVisible); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Board_Renders_Field_Hand_Guardian_Damage_And_Soul() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); board.Render(BuildView(you: 0), new BoardRenderState()); var cards = ActiveCards(); Assert.GreaterOrEqual(cards.Count, 9); Assert.IsTrue(cards.Any(card => card.Ref.Zone == BoardZone.RearGuard && card.Ref.Player == 0 && card.Ref.Slot == 3)); Assert.IsTrue(cards.Any(card => card.Ref.Zone == BoardZone.Guardian && card.Ref.InstanceId == 7)); Assert.IsTrue(cards.Any(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0)); var opponentHand = cards .Where(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 1) .ToList(); Assert.AreEqual(3, opponentHand.Count); Assert.IsTrue(opponentHand.All(card => !card.IsFaceUp)); Assert.IsTrue(cards.Any(card => card.Ref.Zone == BoardZone.Damage && card.Ref.Player == 0)); var rested = cards.Single(card => card.Ref.Zone == BoardZone.RearGuard && card.Ref.Player == 0 && card.Ref.Slot == 3); Assert.IsTrue(rested.IsRested); Assert.AreEqual(90f, Mathf.Abs(Mathf.DeltaAngle(rested.TargetYaw, 0f)), 0.1f); Assert.AreEqual("P8000 C1", rested.StatsText); var hiddenDamage = cards.Single(card => card.Ref.Zone == BoardZone.Damage && card.Ref.Player == 0 && card.Ref.Index == 1); Assert.IsFalse(hiddenDamage.IsFaceUp); Assert.IsTrue(hiddenDamage.IsRested); var visibleDamage = cards.Single(card => card.Ref.Zone == BoardZone.Damage && card.Ref.Player == 0 && card.Ref.Index == 0); Assert.IsTrue(visibleDamage.IsFaceUp); Assert.AreEqual( -90f, Mathf.DeltaAngle(0f, visibleDamage.TargetYaw), 0.1f, "Damage cards must rest counter-clockwise"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Guardian_And_Battle_Guard_Cards_Are_Not_Duplicated() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); var view = BuildView(you: 0); var self = view.Players.Single(player => player.Index == 0); view.Battle = new BattleView { AttackerPlayer = 1, AttackerSlot = 3, TargetSlot = -1, GuardCards = self.Guardian, }; board.Render(view, new BoardRenderState()); var guardianCards = ActiveCards() .Where(card => card.Ref.Zone == BoardZone.Guardian && card.Ref.Player == 0 && card.Ref.InstanceId == 7) .ToList(); Assert.AreEqual( 1, guardianCards.Count, "A guard card present in both Guardian and Battle.GuardCards must render once"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Hand_Is_Anchored_To_Camera_Bottom_Center_And_Faces_Player() { var config = PlaymatLayoutConfig.CreateDefault(); var cameraGo = new GameObject("Main Camera", typeof(Camera)) { tag = "MainCamera", }; var camera = cameraGo.GetComponent(); camera.fieldOfView = config.cameraFov; camera.transform.position = config.cameraPosition; camera.transform.rotation = Quaternion.LookRotation( config.cameraTarget - config.cameraPosition, Vector3.up); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); board.Render(BuildView(you: 0), new BoardRenderState()); var hand = ActiveCards() .Where(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0) .ToList(); Assert.AreEqual(2, hand.Count); foreach (var card in hand) { var viewport = camera.WorldToViewportPoint(card.transform.position); Assert.Greater(viewport.z, 0f, "Hand card must be in front of the camera"); Assert.That(viewport.x, Is.InRange(0.15f, 0.85f)); Assert.That(viewport.y, Is.InRange(0.01f, 0.55f)); var toCamera = (camera.transform.position - card.transform.position).normalized; Assert.Greater( Vector3.Dot(card.transform.up, toCamera), 0.8f, "Hand card must face the camera"); } } finally { Object.DestroyImmediate(board.gameObject); Object.DestroyImmediate(cameraGo); } } [Test] public void Trigger_Zone_Uses_Local_Plus_And_Minus_Ninety_Degrees() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); var view = BuildView(you: 0); var self = view.Players.Single(player => player.Index == 0); var opponent = view.Players.Single(player => player.Index == 1); self.Trigger = new List { new CardView { PrintingId = "BT01/002EN", InstanceId = 31, FaceUp = true, }, }; opponent.Trigger = new List { new CardView { PrintingId = "BT01/003EN", InstanceId = 32, FaceUp = true, }, }; board.Render(view, new BoardRenderState()); var own = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Trigger && card.Ref.Player == 0); var enemy = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Trigger && card.Ref.Player == 1); var layout = new PlaymatLayout(config); Assert.AreEqual( 90f, Mathf.DeltaAngle(layout.BaseYaw(0), own.TargetYaw), 0.1f); Assert.AreEqual( -90f, Mathf.DeltaAngle(layout.BaseYaw(1), enemy.TargetYaw), 0.1f); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Pool_Reuses_Cards_Between_Snapshots() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); var view = BuildView(you: 0); board.Render(view, new BoardRenderState()); var count = ActiveCards().Count; board.Render(view, new BoardRenderState()); Assert.AreEqual(count, ActiveCards().Count); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Local_Seat_Is_Always_Bottom_Of_The_Board() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); board.Render(BuildView(you: 1), new BoardRenderState()); var vanguard = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Vanguard && card.Ref.Player == 1); Assert.Less(vanguard.TargetPosition.z, 0f); var opponent = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Vanguard && card.Ref.Player == 0); Assert.Greater(opponent.TargetPosition.z, 0f); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Highlight_And_Selection_State_Is_Applied_To_Cards() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); var state = new BoardRenderState(); state.Highlights.Add(new BoardSlotKey(0, 3)); state.SelectedHandInstanceIds.Add(12); board.Render(BuildView(you: 0), state); var rested = ActiveCards().Single(card => card.Ref.Zone == BoardZone.RearGuard && card.Ref.Player == 0 && card.Ref.Slot == 3); Assert.IsTrue(rested.transform.Find("Highlight").gameObject.activeSelf); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void CardMoveAnimator_Releases_Input_Lock_When_Card_Is_Cancelled() { var config = PlaymatLayoutConfig.CreateDefault(); var root = new GameObject("AnimatorRoot"); var card = Card3D.Create(root.transform, config); var input = new InputLockService(); var animator = new CardMoveAnimator(input, AnimationConfig.Default); try { var from = new Vector3(-1f, 0.1f, 0f); var to = new Vector3(1f, 0.1f, 0f); card.PlaceImmediate(from, Quaternion.identity, Vector3.one); card.SetPose(to, Quaternion.identity, true, false, false); animator.Enqueue( card, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f); animator.Tick(0.01f); Assert.IsTrue(input.IsLocked); animator.CancelCard(card); Assert.IsFalse(input.IsLocked); } finally { animator.CancelAll(); Object.DestroyImmediate(root); } } [Test] public void Swapping_Rear_Guards_Does_Not_Release_Or_Duplicate_Cards() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); var view = BuildView(you: 0); var self = view.Players.Single(player => player.Index == 0); var first = new CardView { PrintingId = "BT01/002EN", InstanceId = 41, Power = 5000, BasePower = 5000, Critical = 1, BaseCritical = 1, }; var second = new CardView { PrintingId = "BT01/003EN", InstanceId = 42, Power = 5000, BasePower = 5000, Critical = 1, BaseCritical = 1, }; self.RearGuards[0] = first; self.RearGuards[2] = second; board.Render(view, new BoardRenderState()); self.RearGuards[0] = second; self.RearGuards[2] = first; board.Render(view, new BoardRenderState()); var cards = ActiveCards() .Where(card => card.Ref.Zone == BoardZone.RearGuard && card.Ref.Player == 0 && (card.Ref.InstanceId == 41 || card.Ref.InstanceId == 42)) .ToList(); Assert.AreEqual(2, cards.Count); Assert.AreEqual( 2, cards.Select(card => card.Ref.InstanceId).Distinct().Count()); Assert.IsTrue(cards.All(card => card.gameObject.activeSelf)); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Card_Arc_Move_Interpolates_And_Finishes_At_Target() { var config = PlaymatLayoutConfig.CreateDefault(); var root = new GameObject("ArcRoot"); var card = Card3D.Create(root.transform, config); try { var from = new Vector3(-1f, 0.1f, 0f); var to = new Vector3(1f, 0.1f, 0f); card.PlaceImmediate(from, Quaternion.identity, Vector3.one); card.SetPose( to, Quaternion.identity, true, false, false, scale: 1f); card.StartArcMove( from, Quaternion.identity, Vector3.one, 0.5f, 0.4f); card.Tick(0.25f); Assert.IsTrue(card.IsMoving); Assert.Greater(card.transform.position.y, from.y); card.Tick(0.5f); Assert.IsFalse(card.IsMoving); Assert.Less( Vector3.Distance(card.transform.position, to), 0.001f); } finally { Object.DestroyImmediate(root); } } [Test] public void Repeated_Render_Reuses_Hidden_Cards_And_Does_Not_Enqueue_Moves() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); var view = BuildView(you: 0); board.Render(view, new BoardRenderState()); board.MoveAnimator.Tick(0.01f); var hidden = ActiveCards() .Where(card => (card.Ref.Zone == BoardZone.Hand && card.Ref.Player != view.You) || (card.Ref.Zone == BoardZone.Damage && card.Ref.InstanceId == 0)) .ToList(); var hiddenCount = hidden.Count; board.Render(view, new BoardRenderState()); var hiddenAfter = ActiveCards() .Where(card => (card.Ref.Zone == BoardZone.Hand && card.Ref.Player != view.You) || (card.Ref.Zone == BoardZone.Damage && card.Ref.InstanceId == 0)) .ToList(); Assert.AreEqual(hiddenCount, hiddenAfter.Count); Assert.AreEqual( 0, board.MoveAnimator.PendingCount, "Repeated render should not queue new moves for stable hidden cards"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Revealing_Face_Down_Damage_Reuses_Card_And_Stays_In_Damage_Zone() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); var view = BuildView(you: 0); var self = view.Players.Single(player => player.Index == view.You); board.Render(view, new BoardRenderState()); var hidden = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Damage && card.Ref.Player == view.You && card.Ref.Index == 1); self.FaceUpDamage.Add(new CardView { PrintingId = "BT01/099EN", InstanceId = 99, FaceUp = true, }); self.FaceDownDamage = 1; board.Render(view, new BoardRenderState()); var revealed = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Damage && card.Ref.Player == view.You && card.Ref.Index == 1); Assert.AreSame(hidden, revealed, "Revealed damage must reuse Card3D"); var layout = new PlaymatLayout(config); var expected = layout.DamagePosition(0, 1); Assert.Less( Vector3.Distance(revealed.transform.position, expected), 0.5f, "Revealed card should stay in damage zone, not fly from deck"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void New_Field_Unit_Starts_From_Hand_Not_Deck() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); var view = BuildView(you: 0); var self = view.Players.Single(player => player.Index == view.You); board.Render(view, new BoardRenderState()); var handCard = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == view.You && card.Ref.InstanceId == 11); var handPosition = handCard.transform.position; var cardView = self.Hand.First(card => card.InstanceId == 11); self.Hand.Remove(cardView); self.RearGuards[0] = cardView; board.Render(view, new BoardRenderState()); var fieldCard = ActiveCards().Single(card => card.Ref.Zone == BoardZone.RearGuard && card.Ref.Player == view.You && card.Ref.Slot == 0); Assert.AreSame(handCard, fieldCard); Assert.Less( Vector3.Distance(fieldCard.transform.position, handPosition), 0.1f, "New field unit should start at hand position"); Assert.Greater( Vector3.Distance(fieldCard.transform.position, board.Layout.DeckPosition(0)), 0.5f, "New field unit should not start at deck"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void CardMoveAnimator_Coalesces_Requests_For_Same_Card() { var input = new InputLockService(); var animator = new CardMoveAnimator(input, AnimationConfig.Default); var root = new GameObject("Root"); var cardA = Card3D.Create(root.transform, PlaymatLayoutConfig.Default); var cardB = Card3D.Create(root.transform, PlaymatLayoutConfig.Default); try { var from = new Vector3(-1f, 0.1f, 0f); var to = new Vector3(1f, 0.1f, 0f); cardA.PlaceImmediate(from, Quaternion.identity, Vector3.one); cardA.SetPose(to, Quaternion.identity, true, false, false); animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f); Assert.AreEqual(1, animator.PendingCount); // Вторая команда той же карты должна заменить первую, // а не добавиться в очередь. animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.4f, 0.3f); Assert.AreEqual(1, animator.PendingCount); Assert.AreEqual(0, animator.ActiveCount); // Другая карта — отдельный запрос. cardB.PlaceImmediate(from, Quaternion.identity, Vector3.one); cardB.SetPose(to, Quaternion.identity, true, false, false); animator.Enqueue(cardB, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f); Assert.AreEqual(2, animator.PendingCount); animator.CancelAll(); // Новый запрос во время активного движения отменяет старый // и снова становится единственным. cardA.PlaceImmediate(from, Quaternion.identity, Vector3.one); cardA.SetPose(to, Quaternion.identity, true, false, false); animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f); animator.Tick(0.2f); Assert.AreEqual(1, animator.ActiveCount); animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f); Assert.AreEqual(1, animator.ActiveCount + animator.PendingCount); } finally { animator.CancelAll(); Object.DestroyImmediate(root); } } private static List ActiveCards() => Resources.FindObjectsOfTypeAll() .Where(card => card.gameObject.activeInHierarchy) .ToList(); private static MatchView BuildView(int you) { CardView Card(string id, int instance, bool rested = false, bool faceUp = true) => new CardView { PrintingId = id, InstanceId = instance, Rested = rested, FaceUp = faceUp, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }; var self = new PlayerView { Index = you, Name = "Self", Vanguard = Card("BT01/001EN", 1), RearGuards = new CardView[5], Hand = new List { Card("BT01/002EN", 11), Card("BT01/003EN", 12), }, Guardian = new List { Card("BT01/004EN", 7) }, Soul = new List { Card("BT01/005EN", 8) }, FaceUpDamage = new List { Card("BT01/006EN", 9) }, FaceDownDamage = 2, DeckCount = 40, Drop = new List(), }; self.RearGuards[3] = Card("BT01/007EN", 5, rested: true); var otherIndex = 1 - you; var opponent = new PlayerView { Index = otherIndex, Name = "Opponent", Vanguard = Card("BT01/010EN", 21), RearGuards = new CardView[5], Hand = new List(), HandCount = 3, Guardian = new List(), Soul = new List(), FaceUpDamage = new List(), FaceDownDamage = 0, DeckCount = 40, Drop = new List(), }; opponent.RearGuards[4] = Card("BT01/011EN", 22, rested: true); return new MatchView { MatchId = "board-test", You = you, ActivePlayer = you, Phase = GamePhase.Main, Players = new List { self, opponent }, Clock = new ClockView(), LegalIntents = new List(), }; } } }