diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index edb2d78..7d4c70c 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -347,7 +347,7 @@ namespace Vanguard.UI private void UpdateAutoPhases() { - if (_view == null || _sending || _inputLock.IsLocked) + if (_view == null || _sending || _reconnecting) return; var legal = _view.LegalIntents ?? new List(); @@ -372,12 +372,17 @@ namespace Vanguard.UI { _autoFinishMulligan = false; _autoFinishMulliganAt = 0f; - OnIntentClicked(finish); + // After an exchange the mulligan must finish by itself; + // feedback/overlay locks must not block that transition. + OnIntentClicked(finish, ignoreInputLock: true); } } } } + if (_inputLock.IsLocked) + return; + var battle = _view.Phase == GamePhase.Battle && _view.ActivePlayer == _view.You && !_view.IsFinished; @@ -662,6 +667,15 @@ namespace Vanguard.UI _view = snapshot.View; _finished = _view.IsFinished; _pendingIntents.Clear(); + if (_mulliganUsed + && _view.Phase == GamePhase.Mulligan + && _view.ActivePlayer == _view.You) + { + // Recovery path: if the automatic finish was interrupted by a + // reconnect, re-arm it from the authoritative snapshot. + _autoFinishMulligan = true; + _autoFinishMulliganAt = 0f; + } FinishDiscardSelection(); _triggerPowerSlot = null; PrepareTriggerSelection(); @@ -988,12 +1002,15 @@ namespace Vanguard.UI } } - private async void OnIntentClicked(IntentPayload intent) + private void OnIntentClicked(IntentPayload intent) => + OnIntentClicked(intent, ignoreInputLock: false); + + private async void OnIntentClicked(IntentPayload intent, bool ignoreInputLock) { if (_sending || _connection == null || _cts == null || _view == null) return; - if (_inputLock.IsLocked && intent is not ConcedeIntent) + if (!ignoreInputLock && _inputLock.IsLocked && intent is not ConcedeIntent) return; if (_discardChoiceActive) @@ -1028,6 +1045,13 @@ namespace Vanguard.UI if (!result.Accepted) { + if (intent is MulliganIntent || intent is FinishMulliganIntent) + { + _mulliganUsed = false; + _autoFinishMulligan = false; + _autoFinishMulliganAt = 0f; + } + SetStatus( "Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"), new Color(1f, 0.55f, 0.45f)); @@ -1036,6 +1060,17 @@ namespace Vanguard.UI } else { + if (intent is MulliganIntent) + { + // The exchange itself completes the mulligan; finish it + // automatically even if a feedback/overlay lock is active. + _mulliganUsed = true; + _autoFinishMulligan = true; + _autoFinishMulliganAt = Time.unscaledTime + 0.3f; + if (_view != null) + RebuildPhaseControls(_view); + } + SetStatus(result.Duplicate ? "Повтор уже применён." : "Принято.", Muted); await RequestSnapshotAsync(); } @@ -1977,6 +2012,9 @@ namespace Vanguard.UI var intents = (view.LegalIntents ?? new List()) .Where(IsPhaseIntent) .Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1)) + // The exchange is followed by an automatic finish; showing an + // inactive finish button after the exchange is misleading. + .Where(intent => !(intent is FinishMulliganIntent && _mulliganUsed)) .ToList(); if (intents.Count == 0) { @@ -2274,6 +2312,8 @@ namespace Vanguard.UI _mulliganUsed = true; _autoFinishMulligan = true; _autoFinishMulliganAt = 0f; + if (_view != null) + RebuildPhaseControls(_view); OnIntentClicked(new MulliganIntent { HandIndexes = indexes }); } diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index c60c47e..236c341 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -196,6 +196,68 @@ namespace Vanguard.Tests } } + [Test] + public void Mulligan_Finish_Button_Is_Hidden_After_Exchange() + { + var config = new AppConfig(); + config.Load(); + var tokens = new TokenStore(); + var api = new ApiClient(config, tokens); + var player = new PlayerState(); + var auth = new AuthService(api, tokens, player); + var meta = new MetaService(api, player); + var canvas = UiKit.CreateCanvas("Mulligan Test Canvas"); + + try + { + var battle = new BattleView(canvas.transform, meta, () => { }); + var match = new MatchView + { + You = 0, + ActivePlayer = 0, + Phase = GamePhase.Mulligan, + LegalIntents = new List + { + new FinishMulliganIntent(), + new ConcedeIntent(), + }, + }; + + var viewField = typeof(BattleView) + .GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!; + var usedField = typeof(BattleView) + .GetField("_mulliganUsed", BindingFlags.Instance | BindingFlags.NonPublic)!; + var rebuild = typeof(BattleView) + .GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!; + + viewField.SetValue(battle, match); + + usedField.SetValue(battle, true); + rebuild.Invoke(battle, new object[] { match }); + Assert.IsFalse( + FindRect("PhasePanel").gameObject.activeSelf, + "Phase panel must disappear after the mulligan exchange"); + Assert.IsFalse( + Resources.FindObjectsOfTypeAll() + .Any(go => go.name == "Phase_FinishMulliganIntent"), + "Finish mulligan button must not remain after the exchange"); + + usedField.SetValue(battle, false); + rebuild.Invoke(battle, new object[] { match }); + Assert.IsTrue( + FindRect("PhasePanel").gameObject.activeSelf, + "Finish mulligan button must be available before the exchange"); + Assert.IsTrue( + Resources.FindObjectsOfTypeAll() + .Any(go => go.name == "Phase_FinishMulliganIntent")); + } + finally + { + Object.DestroyImmediate(canvas.gameObject); + Cleanup(); + } + } + private static RectTransform FindRect(string name) => Resources.FindObjectsOfTypeAll() .Where(go => go.scene.IsValid() && go.name == name) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 6789713..5cb58ff 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1001,7 +1001,10 @@ Battle; Boot и общие сервисы остаются персистент строке, чтобы видеть поле. При скрытии панель фаз и UI противника сдвигаются вправо. - Кнопка муллигана в панели «Фазы» динамическая: без выбора — «Закончить - муллиган», с выбором — «Заменить X карт». + муллиган», с выбором — «Заменить X карт». После обмена муллиган + завершается автоматически, кнопка «Закончить муллиган» скрывается и не + блокируется feedback/overlay-локом; при реконнекте авто-завершение + взводится повторно из снапшота. - Trigger-зона противника зеркальна: над его колодой в сторону центра, а не за дальним краем. - Карты на столе и в руке масштабируются общим `cardScale = 1.25` @@ -1024,4 +1027,4 @@ Battle; Boot и общие сервисы остаются персистент (карты, которые сервер должен предложить в фазе Ride). Смотреть: `ssh home "docker compose -f ~/VanguardReMaster/docker-compose.yml logs -f server"`. - Проверки: сервер **142** правил + **45** серверных, `validate` и - `translations check` OK; клиент EditMode **30/30**. + `translations check` OK; клиент EditMode **31/31**. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 7810d3e..180e5e8 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -206,12 +206,13 @@ Standalone Windows: -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat ``` -Клиентские проверки: **30/30** EditMode (сборка всех экранов, импорт арта, +Клиентские проверки: **31/31** EditMode (сборка всех экранов, импорт арта, перевод статических/динамических строк, live-переключение языка, рендер 3D-доски с уроном и отдыхающей картой, геометрия поля, правила перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона, CardListView/Detail, зоны и рука противника, InputLock, контекстное меню, -стрелка атаки, prefab-оверлеи, раскладка на 1920×1080), `SCENE_SMOKE +стрелка атаки, prefab-оверлеи, раскладка на 1920×1080, авто-завершение +муллигана без кнопки после обмена), `SCENE_SMOKE success=True`, `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`. `SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены