Auto-finish mulligan after exchanging cards
CI / test (push) Successful in 2m23s Details

This commit is contained in:
MpakobecEx 2026-10-23 17:50:19 +05:00
parent 8388d877b3
commit d6784b7ca9
4 changed files with 114 additions and 8 deletions

View File

@ -347,7 +347,7 @@ namespace Vanguard.UI
private void UpdateAutoPhases() private void UpdateAutoPhases()
{ {
if (_view == null || _sending || _inputLock.IsLocked) if (_view == null || _sending || _reconnecting)
return; return;
var legal = _view.LegalIntents ?? new List<IntentPayload>(); var legal = _view.LegalIntents ?? new List<IntentPayload>();
@ -372,12 +372,17 @@ namespace Vanguard.UI
{ {
_autoFinishMulligan = false; _autoFinishMulligan = false;
_autoFinishMulliganAt = 0f; _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 var battle = _view.Phase == GamePhase.Battle
&& _view.ActivePlayer == _view.You && _view.ActivePlayer == _view.You
&& !_view.IsFinished; && !_view.IsFinished;
@ -662,6 +667,15 @@ namespace Vanguard.UI
_view = snapshot.View; _view = snapshot.View;
_finished = _view.IsFinished; _finished = _view.IsFinished;
_pendingIntents.Clear(); _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(); FinishDiscardSelection();
_triggerPowerSlot = null; _triggerPowerSlot = null;
PrepareTriggerSelection(); 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) if (_sending || _connection == null || _cts == null || _view == null)
return; return;
if (_inputLock.IsLocked && intent is not ConcedeIntent) if (!ignoreInputLock && _inputLock.IsLocked && intent is not ConcedeIntent)
return; return;
if (_discardChoiceActive) if (_discardChoiceActive)
@ -1028,6 +1045,13 @@ namespace Vanguard.UI
if (!result.Accepted) if (!result.Accepted)
{ {
if (intent is MulliganIntent || intent is FinishMulliganIntent)
{
_mulliganUsed = false;
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
}
SetStatus( SetStatus(
"Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"), "Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"),
new Color(1f, 0.55f, 0.45f)); new Color(1f, 0.55f, 0.45f));
@ -1036,6 +1060,17 @@ namespace Vanguard.UI
} }
else 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); SetStatus(result.Duplicate ? "Повтор уже применён." : "Принято.", Muted);
await RequestSnapshotAsync(); await RequestSnapshotAsync();
} }
@ -1977,6 +2012,9 @@ namespace Vanguard.UI
var intents = (view.LegalIntents ?? new List<IntentPayload>()) var intents = (view.LegalIntents ?? new List<IntentPayload>())
.Where(IsPhaseIntent) .Where(IsPhaseIntent)
.Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1)) .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(); .ToList();
if (intents.Count == 0) if (intents.Count == 0)
{ {
@ -2274,6 +2312,8 @@ namespace Vanguard.UI
_mulliganUsed = true; _mulliganUsed = true;
_autoFinishMulligan = true; _autoFinishMulligan = true;
_autoFinishMulliganAt = 0f; _autoFinishMulliganAt = 0f;
if (_view != null)
RebuildPhaseControls(_view);
OnIntentClicked(new MulliganIntent { HandIndexes = indexes }); OnIntentClicked(new MulliganIntent { HandIndexes = indexes });
} }

View File

@ -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<IntentPayload>
{
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<GameObject>()
.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<GameObject>()
.Any(go => go.name == "Phase_FinishMulliganIntent"));
}
finally
{
Object.DestroyImmediate(canvas.gameObject);
Cleanup();
}
}
private static RectTransform FindRect(string name) => private static RectTransform FindRect(string name) =>
Resources.FindObjectsOfTypeAll<GameObject>() Resources.FindObjectsOfTypeAll<GameObject>()
.Where(go => go.scene.IsValid() && go.name == name) .Where(go => go.scene.IsValid() && go.name == name)

View File

@ -1001,7 +1001,10 @@ Battle; Boot и общие сервисы остаются персистент
строке, чтобы видеть поле. При скрытии панель фаз и UI противника строке, чтобы видеть поле. При скрытии панель фаз и UI противника
сдвигаются вправо. сдвигаются вправо.
- Кнопка муллигана в панели «Фазы» динамическая: без выбора — «Закончить - Кнопка муллигана в панели «Фазы» динамическая: без выбора — «Закончить
муллиган», с выбором — «Заменить X карт». муллиган», с выбором — «Заменить X карт». После обмена муллиган
завершается автоматически, кнопка «Закончить муллиган» скрывается и не
блокируется feedback/overlay-локом; при реконнекте авто-завершение
взводится повторно из снапшота.
- Trigger-зона противника зеркальна: над его колодой в сторону центра, - Trigger-зона противника зеркальна: над его колодой в сторону центра,
а не за дальним краем. а не за дальним краем.
- Карты на столе и в руке масштабируются общим `cardScale = 1.25` - Карты на столе и в руке масштабируются общим `cardScale = 1.25`
@ -1024,4 +1027,4 @@ Battle; Boot и общие сервисы остаются персистент
(карты, которые сервер должен предложить в фазе Ride). Смотреть: (карты, которые сервер должен предложить в фазе Ride). Смотреть:
`ssh home "docker compose -f ~/VanguardReMaster/docker-compose.yml logs -f server"`. `ssh home "docker compose -f ~/VanguardReMaster/docker-compose.yml logs -f server"`.
- Проверки: сервер **142** правил + **45** серверных, `validate` и - Проверки: сервер **142** правил + **45** серверных, `validate` и
`translations check` OK; клиент EditMode **30/30**. `translations check` OK; клиент EditMode **31/31**.

View File

@ -206,12 +206,13 @@ Standalone Windows:
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
``` ```
Клиентские проверки: **30/30** EditMode (сборка всех экранов, импорт арта, Клиентские проверки: **31/31** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер перевод статических/динамических строк, live-переключение языка, рендер
3D-доски с уроном и отдыхающей картой, геометрия поля, правила 3D-доски с уроном и отдыхающей картой, геометрия поля, правила
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона, перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
CardListView/Detail, зоны и рука противника, InputLock, контекстное меню, 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`. success=True`, `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены `SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены