using System.Collections.Generic; using System.Linq; using System.Reflection; 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("\u25B28000", 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 Hands_Fill_Mirrored_And_Newest_Is_On_Top() { 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 player = ActiveCards() .Where(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0) .OrderBy(card => card.Ref.Index) .ToList(); var opponent = ActiveCards() .Where(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 1) .OrderBy(card => card.Ref.Index) .ToList(); Assert.AreEqual(2, player.Count); Assert.AreEqual(3, opponent.Count); var playerOldest = camera.WorldToViewportPoint( player[0].transform.position); var playerNewest = camera.WorldToViewportPoint( player[1].transform.position); Assert.Less( playerOldest.x, playerNewest.x, "Player hand fills left-to-right: the newest card is on the right"); Assert.Less( playerNewest.z, playerOldest.z, "Player newest card must be closer to the camera"); var opponentOldest = camera.WorldToViewportPoint( opponent[0].transform.position); var opponentNewest = camera.WorldToViewportPoint( opponent[2].transform.position); Assert.Greater( opponentOldest.x, opponentNewest.x, "Opponent hand fills right-to-left: the newest card is on the left"); Assert.Less( opponentNewest.z, opponentOldest.z, "Opponent newest card must be closer to the camera"); var view = BuildView(you: 0); view.Players[0].Hand.Add(new CardView { PrintingId = "BT01/008EN", InstanceId = 13, FaceUp = true, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }); view.Players[1].HandCount = 4; board.Render(view, new BoardRenderState(), animateMoves: false); var newPlayer = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 2); var newOpponent = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 1 && card.Ref.Index == 3); Assert.Greater( camera.WorldToViewportPoint(newPlayer.TargetPosition).x, playerNewest.x, "A newly drawn player card must appear to the right"); Assert.Less( camera.WorldToViewportPoint(newOpponent.TargetPosition).x, opponentNewest.x, "A newly drawn opponent card must appear to the left"); var drawSource = board.Layout.DeckPosition(0) + (Vector3.up * 0.03f); Assert.Less( Vector3.Distance(newPlayer.transform.position, drawSource), 0.01f, "A drawn card must start above the deck"); Assert.AreEqual( config.cardScale, newPlayer.transform.localScale.x, 0.01f, "A drawn card must start at deck scale, not hand scale"); } finally { Object.DestroyImmediate(board.gameObject); Object.DestroyImmediate(cameraGo); } } [Test] public void Hand_Reflows_While_A_Card_Leaves_From_Hand() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { var view = BuildView(you: 0); var leaving = new CardView { PrintingId = "BT01/008EN", InstanceId = 13, FaceUp = true, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }; view.Players[0].Hand.Add(leaving); board.SetVisible(true); board.Render(view, new BoardRenderState()); var oldestBefore = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0).TargetPosition; var next = BuildView(you: 0); next.Players[0].RearGuards[3] = leaving; board.Render(next, new BoardRenderState()); var oldestAfter = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0).TargetPosition; Assert.Greater( Vector3.Distance(oldestBefore, oldestAfter), 0.01f, "The hand must start reflowing when a card leaves"); Assert.GreaterOrEqual( board.MoveAnimator.PendingCount, 1, "The leaving card must still be queued for movement"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Dropped_Hand_Card_Holds_At_Drop_Until_Returned() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { board.SetVisible(true); board.Render(BuildView(you: 0), new BoardRenderState()); var card = ActiveCards().Single(item => item.Ref.Zone == BoardZone.Hand && item.Ref.Player == 0 && item.Ref.Index == 0); var handTarget = card.TargetPosition; card.BeginDrag(); card.DragTo(new Vector3(1f, 0f, 1f)); var held = card.transform.position; card.EndDragAtCurrent(); Assert.IsFalse(card.IsDragging); Assert.Less( Vector3.Distance(card.TargetPosition, held), 0.001f, "Dropped card must stay at the drop point"); card.Tick(0.2f); Assert.Less( Vector3.Distance(card.transform.position, held), 0.001f, "Dropped card must not twitch while waiting for a snapshot"); var pendingField = typeof(GameBoard3D).GetField( "_dropPendingCard", BindingFlags.Instance | BindingFlags.NonPublic); Assert.IsNotNull(pendingField); pendingField.SetValue(board, card); board.ReturnDroppedCard(); Assert.Less( Vector3.Distance(card.TargetPosition, handTarget), 0.001f, "Returned card must target its hand pose again"); card.Tick(0.5f); Assert.Less( Vector3.Distance(card.transform.position, handTarget), 0.05f); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Hand_Hover_Lifts_Card_By_Ten_Percent() { 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 handCard = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0); var before = handCard.TargetPosition; var hoverField = typeof(GameBoard3D).GetField( "_hoveredHandCard", BindingFlags.Instance | BindingFlags.NonPublic); var updateHand = typeof(GameBoard3D).GetMethod( "UpdateHandView", BindingFlags.Instance | BindingFlags.NonPublic); Assert.IsNotNull(hoverField); Assert.IsNotNull(updateHand); hoverField.SetValue(board, handCard); updateHand.Invoke(board, null); var lifted = handCard.TargetPosition; var delta = lifted - before; Assert.Greater(delta.magnitude, 0.01f); Assert.Greater( Vector3.Dot(delta.normalized, camera.transform.up), 0.9f, "Hover lift must go up in camera space"); hoverField.SetValue(board, null); updateHand.Invoke(board, null); Assert.Less( Vector3.Distance(handCard.TargetPosition, before), 0.001f, "Unhover must return the card to its hand pose"); } finally { Object.DestroyImmediate(board.gameObject); Object.DestroyImmediate(cameraGo); } } [Test] public void Hand_Draw_Stages_New_Cards_One_By_One() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { var view = BuildView(you: 0); board.SetVisible(true); board.Render(view, new BoardRenderState()); var oldestTargetBefore = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0).TargetPosition; var next = BuildView(you: 0); next.Players[0].Hand.Add(new CardView { PrintingId = "BT01/008EN", InstanceId = 13, FaceUp = true, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }); next.Players[0].Hand.Add(new CardView { PrintingId = "BT01/009EN", InstanceId = 14, FaceUp = true, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }); board.Render(next, new BoardRenderState()); Assert.IsTrue(board.StagedDrawHandActive); Assert.AreEqual(2, board.StagedDrawCards.Count); Assert.AreEqual(2, board.MoveAnimator.PendingCount); Assert.AreEqual(2, ActiveCards().Count(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0)); // Повторный snapshot без новых доборов не должен сбрасывать // уже начатую ступенчатую раздачу. board.Render(next, new BoardRenderState()); Assert.IsTrue(board.StagedDrawHandActive); Assert.AreEqual(2, board.StagedDrawCards.Count); var oldestTargetAtDrawStart = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0).TargetPosition; Assert.Less( Vector3.Distance( oldestTargetBefore, oldestTargetAtDrawStart), 0.001f, "The hand must not reflow before the first draw arrives"); board.MoveAnimator.Tick(0f); Assert.AreEqual(1, board.MoveAnimator.ActiveCount); var firstDrawn = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 2); firstDrawn.Tick(0.7f); board.MoveAnimator.Tick(0f); Assert.AreEqual(3, board.StagedDrawCards.Count); Assert.IsTrue(board.StagedDrawHandActive); var oldestTargetAfter = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0).TargetPosition; Assert.Greater( Vector3.Distance(oldestTargetBefore, oldestTargetAfter), 0.01f, "The hand must reflow after the first drawn card arrives"); Assert.AreEqual(3, ActiveCards().Count(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0)); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Mulligan_Remaining_Cards_Reflow_After_Each_Return() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { var view = BuildView(you: 0); view.Players[0].Hand.Add(new CardView { PrintingId = "BT01/008EN", InstanceId = 13, FaceUp = true, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }); view.Players[0].Hand.Add(new CardView { PrintingId = "BT01/009EN", InstanceId = 14, FaceUp = true, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }); board.SetVisible(true); board.Render(view, new BoardRenderState()); var oldestTargetBefore = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0).TargetPosition; var next = BuildView(you: 0); next.Players[0].Hand.Add(new CardView { PrintingId = "BT01/010EN", InstanceId = 15, FaceUp = true, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }); next.Players[0].Hand.Add(new CardView { PrintingId = "BT01/011EN", InstanceId = 16, FaceUp = true, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }); board.QueueHandReturnToDeck(0, 2); board.Render(next, new BoardRenderState()); Assert.IsTrue(board.StagedReturnHandActive); Assert.AreEqual(4, board.StagedReturnCards.Count); Assert.IsTrue(board.StagedDrawHandActive); // Первый возврат. board.MoveAnimator.Tick(0f); board.ReturningCards[0].Tick(0.7f); board.MoveAnimator.Tick(0f); // Второй возврат. board.MoveAnimator.Tick(0.3f); board.ReturningCards[0].Tick(0.7f); board.MoveAnimator.Tick(0f); Assert.IsFalse(board.StagedReturnHandActive); Assert.AreEqual(2, board.StagedReturnCards.Count); Assert.IsTrue(board.StagedDrawHandActive); var oldestTargetAfter = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0).TargetPosition; Assert.Greater( Vector3.Distance(oldestTargetBefore, oldestTargetAfter), 0.01f, "Remaining mulligan cards must reflow after returns"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Hand_Return_To_Deck_Starts_Right_To_Left_And_Targets_Deck_Bottom() { var config = PlaymatLayoutConfig.CreateDefault(); var board = GameBoard3D.Create(null, config); try { var view = BuildView(you: 0); view.Players[0].Hand.Add(new CardView { PrintingId = "BT01/008EN", InstanceId = 13, FaceUp = true, Power = 8000, BasePower = 8000, Critical = 1, BaseCritical = 1, }); board.SetVisible(true); board.Render(view, new BoardRenderState()); var oldHand = ActiveCards() .Where(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0) .ToDictionary(card => card.Ref.Index); var source13 = oldHand[2].transform.position; var source12 = oldHand[1].transform.position; var oldestTargetBefore = oldHand[0].TargetPosition; var next = BuildView(you: 0); next.Players[0].Hand = new List { next.Players[0].Hand[0], }; board.QueueHandReturnToDeck(0, 2); board.Render(next, new BoardRenderState()); Assert.AreEqual(2, board.ReturningCardCount); var returning = board.ReturningCards; Assert.AreEqual(2, returning[0].Ref.Index); Assert.AreEqual(1, returning[1].Ref.Index); Assert.Less( Vector3.Distance(returning[0].transform.position, source13), 0.001f, "The rightmost return card must wait in the hand until its turn"); Assert.Less( Vector3.Distance(returning[1].transform.position, source12), 0.001f, "The next return card must stay in the hand until the first finishes"); Assert.IsTrue(board.StagedReturnHandActive); Assert.AreEqual(3, board.StagedReturnCards.Count); var oldestAfter = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0).TargetPosition; Assert.Less( Vector3.Distance(oldestTargetBefore, oldestAfter), 0.001f, "The hand must not reflow before the first card leaves"); var deckBottom = board.Layout.DeckPosition(0) - (Vector3.up * 0.04f); Assert.IsTrue(board.TryGetHandReturnTarget( returning[0], out var returnTarget, out var returnRotation)); Assert.Less( Vector3.Distance(returnTarget, deckBottom), 0.001f); Assert.Greater( Vector3.Dot(returnRotation * Vector3.up, Vector3.down), 0.9f); Assert.AreEqual(2, board.MoveAnimator.PendingCount); board.MoveAnimator.Tick(0f); Assert.IsTrue(returning[0].IsMoving); returning[0].Tick(0.7f); board.MoveAnimator.Tick(0f); Assert.AreEqual(1, board.ReturningCardCount); Assert.AreEqual(2, board.StagedReturnCards.Count); var oldestAfterFirst = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0 && card.Ref.Index == 0).TargetPosition; Assert.Greater( Vector3.Distance(oldestTargetBefore, oldestAfterFirst), 0.01f, "The hand must reflow after the first card leaves"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void CardMoveAnimator_Runs_Sequential_Requests_And_Completes_Each() { 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); cardB.PlaceImmediate(from, Quaternion.identity, Vector3.one); cardB.SetPose(to, Quaternion.identity, true, false, false); var completed = new List(); animator.Enqueue( cardA, from, Quaternion.identity, Vector3.one, 0.1f, 0.2f, sequential: true, onCompleted: card => completed.Add(card)); animator.Enqueue( cardB, from, Quaternion.identity, Vector3.one, 0.1f, 0.2f, sequential: true, onCompleted: card => completed.Add(card)); animator.Tick(0f); Assert.AreEqual(1, animator.ActiveCount); Assert.AreEqual(1, animator.PendingCount); cardA.Tick(0.2f); animator.Tick(0f); CollectionAssert.AreEqual(new[] { cardA }, completed); Assert.AreEqual(0, animator.ActiveCount); Assert.AreEqual(1, animator.PendingCount); animator.Tick(0.3f); Assert.AreEqual(1, animator.ActiveCount); cardB.Tick(0.2f); animator.Tick(0f); CollectionAssert.AreEqual(new[] { cardA, cardB }, completed); Assert.IsFalse(animator.IsBusy); } finally { animator.CancelAll(); Object.DestroyImmediate(root); } } [Test] public void Trigger_Zone_Uses_Local_Minus_And_Plus_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 Removing_Card_From_Hand_Does_Not_Queue_Arcs_For_Remaining_Hand() { 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 called = self.Hand.First(card => card.InstanceId == 11); self.Hand.Remove(called); self.RearGuards[0] = called; board.Render(view, new BoardRenderState()); var remaining = ActiveCards().Single(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == view.You && card.Ref.InstanceId == 12); Assert.IsFalse( board.MoveAnimator.IsCardQueued(remaining), "Remaining hand cards must reflow without arc requests"); Assert.IsFalse( board.MoveAnimator.IsCardActive(remaining), "Remaining hand cards must reflow without active arc requests"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Queued_Card_Holds_At_Source_Until_Arc_Starts() { 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 first = self.Hand[0]; var second = self.Hand[1]; self.Hand.RemoveAt(1); self.Hand.RemoveAt(0); self.RearGuards[0] = first; self.RearGuards[1] = second; board.Render(view, new BoardRenderState()); board.MoveAnimator.Tick(0.001f); var firstCard = ActiveCards().Single(card => card.Ref.Zone == BoardZone.RearGuard && card.Ref.Player == view.You && card.Ref.Slot == 0); var secondCard = ActiveCards().Single(card => card.Ref.Zone == BoardZone.RearGuard && card.Ref.Player == view.You && card.Ref.Slot == 1); Assert.IsTrue(firstCard.IsMoving || board.MoveAnimator.IsCardActive(firstCard), "First card should already be moving"); Assert.IsTrue(board.MoveAnimator.IsCardQueued(secondCard), "Second card should be queued behind the first"); Assert.Greater( Vector3.Distance(secondCard.transform.position, secondCard.TargetPosition), 0.5f, "Queued card must stay at source, not smooth-move toward target"); board.MoveAnimator.Tick(10f); Assert.IsTrue(secondCard.IsMoving || board.MoveAnimator.IsCardActive(secondCard), "Second card should start moving after the first finishes"); } finally { Object.DestroyImmediate(board.gameObject); } } [Test] public void Cancel_Queued_Move_Releases_Hold() { 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); 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); animator.Tick(0.001f); Assert.IsTrue(animator.IsCardQueued(cardB)); animator.CancelCard(cardB); Assert.IsFalse(animator.IsCardQueued(cardB)); cardB.Tick(0.5f); Assert.Less( Vector3.Distance(cardB.transform.position, cardB.TargetPosition), 0.5f, "Canceled queued card should resume normal movement to target"); } finally { animator.CancelAll(); Object.DestroyImmediate(root); } } [Test] public void Hand_Visibility_Toggles() { var config = PlaymatLayoutConfig.CreateDefault(); 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.Greater(hand.Count, 0); board.SetHandVisible(false); Assert.IsTrue(hand.All(card => !card.gameObject.activeSelf), "Hand cards should be hidden"); board.SetHandVisible(true); Assert.IsTrue(hand.All(card => card.gameObject.activeSelf), "Hand cards should be visible again"); } 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(), }; } } }